Routing
File-based routing, route handlers, middleware, and non-JSON responses
File-Based Route Discovery
Routes are discovered from api/**/*.js. The file path relative to the api/ folder determines the URL pattern. The prefix is configurable via api.pathPrefix in arcway.config.js (default: '/api'):
| File Path | URL Pattern (with default /api prefix) |
|---|---|
api/users/index.js | /api/users |
api/users/search.js | /api/users/search |
api/users/[id].js | /api/users/:id |
api/users/[id]/projects.js | /api/users/:id/projects |
api/billing/invoices/index.js | /api/billing/invoices |
api/billing/invoices/[invoiceId].js | /api/billing/invoices/:invoiceId |
api/files/[...path].js | /api/files/*path (catch-all) |
Path Prefix:
- Default:
'/api'— routes are served at/api/users,/api/users/:id, etc. - Set
api: { pathPrefix: '/v1' }for versioned APIs:/v1/users,/v1/users/:id - Set
api: { pathPrefix: '' }to serve routes without a prefix:/users,/users/:id
Rules:
- The file path within
api/maps to the URL (after the prefix) index.jsmaps to the directory root[param]in filenames become:parampath parameters — merged intoctx.req.query[...rest]catch-all segments match one or more path segments[[...rest]]optional catch-all matches zero or more path segments- Files starting with
_are skipped (reserved for middleware) - Static routes take priority over parameterized routes, which take priority over catch-all routes
Catch-All Routes
Use [...param] to match the rest of the URL path. The matched segments are available as an array in ctx.req.query:
// api/files/[...path].js → matches /api/files/images/photo.png
export const GET = {
handler: async (ctx) => {
const { log } = ctx;
const filePath = ctx.req.query.path; // ['images', 'photo.png']
log.info('File requested', { path: filePath.join('/') });
return { data: { file: filePath.join('/') } };
},
};Use [[...param]] (double brackets) for an optional catch-all that also matches the parent path:
// api/docs/[[...slug]].js → matches /api/docs AND /api/docs/guide/intro
export const GET = {
handler: async (ctx) => {
const slug = ctx.req.query.slug; // undefined for /api/docs, ['guide', 'intro'] for /api/docs/guide/intro
return { data: { slug: slug ?? [] } };
},
};HTTP Methods
Export named constants for each HTTP method you want to handle:
// api/users/[id].js
export const GET = {
schema: {
query: { id: /^\d+$/ },
},
handler: async (ctx) => {
const { db } = ctx;
const user = await db('users').where('id', Number(ctx.req.query.id)).first();
if (!user) {
return { status: 404, error: { code: 'NOT_FOUND', message: 'User not found' } };
}
return { data: user };
},
};
export const PUT = {
schema: {
query: { id: /^\d+$/ },
body: {
'name?': 'string >= 1',
'email?': 'string.email',
},
},
handler: async (ctx) => {
const { db } = ctx;
const id = Number(ctx.req.query.id);
await db('users').where('id', id).update(ctx.req.body);
const user = await db('users').where('id', id).first();
return { data: user };
},
};
export const DELETE = {
schema: {
query: { id: /^\d+$/ },
},
handler: async (ctx) => {
const { db } = ctx;
await db('users').where('id', Number(ctx.req.query.id)).delete();
return { status: 204, data: null };
},
};Supported methods: GET, POST, PUT, PATCH, DELETE.
Route Handlers
Handler Context
Route handlers receive a ctx object containing both infrastructure and request data:
// ctx contains:
{
// Infrastructure
db, // Knex database connection
events, // Event emitter (emit, subscribe)
queue, // Persistent queue (push, pop, remove)
cache, // Key-value cache (get, set, delete, wrap)
files, // File storage (write, read, delete, list, exists)
mail, // Email (send, queue)
log, // Logger (debug, info, warn, error)
// Request
req: {
id, // UUID or from X-Request-Id header
ip, // Client IP address (respects trustProxy config)
method, // HTTP method
path, // URL path
query, // Path params + query string (merged, validated)
body, // Request body (parsed JSON, validated)
rawBody, // Raw request body string (POST/PUT/PATCH/DELETE only)
headers, // Request headers (flattened)
cookies, // Parsed Cookie header
session, // Unsealed session data (if configured)
},
}Path parameters are merged into ctx.req.query. A route file api/users/[id].js accessed at /users/42?expand=profile produces ctx.req.query = { id: '42', expand: 'profile' }. Path params take precedence over query string params with the same name.
Route Config
Each exported method constant is a route config object:
{
schema: {
query: { ... }, // ArkType schema for query + path params
body: { ... }, // ArkType schema for request body
},
parseBody: true, // Default: true. Set false to skip JSON parsing (ctx.req.body = raw string)
meta: {
summary: 'Get user', // For OpenAPI docs
description: '...',
tags: ['users'],
},
handler: async (ctx) => { ... },
}Route Response
Handlers return a response object:
{
status: 200, // Default: 200 (success) or 400 (error)
data: { ... }, // Sent as the response body directly
error: { // Wrapped in { error: ... }
code: 'NOT_FOUND',
message: 'User not found',
details: { ... }, // Optional
},
headers: { ... }, // Custom response headers
session: { userId: 42 }, // Set session cookie
// session: null // Clear session cookie
}Response format:
- Success: the value of
datais sent directly as the response body, with status 200 - Error:
{ "error": { "code": "...", "message": "..." } }with status 400 - Handler exceptions return
500with a generic error (no internal details exposed)
Non-JSON Responses
To return non-JSON content, set a custom Content-Type header:
// api/reports/export.js
export const GET = {
handler: async (ctx) => {
const csv = 'name,email\nAlice,alice@test.com\nBob,bob@test.com';
return {
data: csv,
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="report.csv"',
},
};
},
};Supported data types in response.data:
| Data Type | Behavior |
|---|---|
string | Sent as-is with Content-Length |
Buffer | Sent as raw binary with correct Content-Length |
Readable (Node.js stream) | Piped directly to the HTTP response |
ReadableStream (Web stream) | Converted to Node.js Readable and piped |
Schema Validation with ArkType
export const POST = {
schema: {
query: {
'expand?': "'profile' | 'posts'",
},
body: {
name: 'string >= 1 & string <= 100',
email: 'string.email',
'age?': 'number.integer > 0',
},
},
handler: async (ctx) => {
// ctx.req.body is validated and coerced
const { name, email } = ctx.req.body;
// ...
},
};When validation fails, the framework returns 400 with:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": { }
}
}Middleware
_middleware.js Convention
Place _middleware.js files in the api/ directory tree. They apply to all routes at that path level and below, cascading from parent to child.
api/
├── _middleware.js # Applies to ALL routes (global)
├── users/
│ ├── _middleware.js # Applies to all /users/* routes
│ ├── index.js # GET/POST /users
│ ├── [id].js # GET/PUT/DELETE /users/:id
│ └── admin/
│ ├── _middleware.js # Applies to /users/admin/* (runs after parent)
│ └── index.js # GET /users/admin
└── billing/
└── invoices/
└── index.js # GET /billing/invoicesMiddleware Config
Middleware exports an object (or array of objects) with a handler function. The handler receives the same ctx object as route handlers. Return a response to short-circuit, or return undefined to continue.
// api/users/_middleware.js
export default {
handler: async (ctx) => {
const { log } = ctx;
log.info(`${ctx.req.id}: incoming ${ctx.req.method} ${ctx.req.path}`);
// Return nothing — continue to next middleware or handler
},
};Schema Validation in Middleware
Middleware can declare schema to validate query or body before the handler runs:
// api/_middleware.js — require API key in query string
export default {
schema: {
query: { apiKey: 'string >= 1' },
},
handler: async (ctx) => {
const { log } = ctx;
log.info(`API key: ${ctx.req.query.apiKey}`);
},
};Short-Circuit (e.g., Auth)
Return a response to block the request from reaching the handler:
// api/admin/_middleware.js
export default {
handler: async (ctx) => {
if (!ctx.req.session?.userId) {
return {
status: 401,
error: { code: 'UNAUTHORIZED', message: 'Login required' },
};
}
// Return nothing — continue to handler
},
};Multiple Middleware
Export an array of configs to apply multiple middleware in order:
// api/users/_middleware.js
const logging = {
handler: async (ctx) => {
const { log } = ctx;
log.info(`${ctx.req.id}: incoming`);
},
};
const requireAuth = {
handler: async (ctx) => {
if (!ctx.req.session?.userId) {
return { status: 401, error: { code: 'UNAUTHORIZED', message: 'Login required' } };
}
},
};
export default [logging, requireAuth];Method-Specific Middleware
Use named exports (GET, POST, PUT, PATCH, DELETE) to apply middleware only to specific HTTP methods. The default export still applies to all methods:
// api/_middleware.js
// Default: runs for ALL methods
export default {
handler: async (ctx) => {
const { log } = ctx;
log.info('Request received');
},
};
// Only runs for POST requests
export const POST = {
handler: async (ctx) => {
const { log } = ctx;
if (!ctx.req.body) {
return { status: 400, error: { code: 'MISSING_BODY', message: 'Request body required' } };
}
log.info('POST body validated');
},
};
// Only runs for DELETE requests
export const DELETE = {
handler: async (ctx) => {
if (ctx.req.session?.role !== 'admin') {
return { status: 403, error: { code: 'FORBIDDEN', message: 'Admin access required for deletion' } };
}
},
};Method-specific middleware runs after the default middleware. You can use only method exports without a default export, or combine both.
Premade Middleware
Arcway ships ready-to-use middleware via arcway/middlewares:
requireSession
Checks that ctx.req.session has a specific key (default: userId). Returns 401 if missing.
import { requireSession } from 'arcway/middlewares';
// api/_middleware.js — require login for all routes
export default {
handler: requireSession(),
};
// Custom session key
export default {
handler: requireSession({ sessionKey: 'accountId' }),
};
// Custom error
export default {
handler: requireSession({
status: 403,
errorCode: 'LOGIN_REQUIRED',
message: 'Please log in first',
}),
};corsMiddleware
Per-prefix CORS settings (for global CORS, use server.cors in arcway.config.js):
import { corsMiddleware } from 'arcway/middlewares';
export default {
handler: corsMiddleware({
origin: ['https://app.example.com'],
credentials: true,
}),
};