Routes
Routes are registered by chaining method calls onto server():
import server from '@server/next';
export default server()
.get('/', () => 'Hello from the homepage')
.get('/users/:id', (ctx) => db.users.find(ctx.url.params.id))
.post('/users', (ctx) => db.users.create(ctx.body));A route is a method, a path, an optional options object, and one or more middleware:
.METHOD(path?, options?, ...middleware)path: a string like/users/:id. Omit it (or pass*) to match every path for that method; see Path.options: overrides the server's defaults for this one route, see Options below.middleware: one or more(ctx) => ...functions, run in order. The last one is usually the handler that returns a response; see Middleware for how returning nothing lets the next one run.
export default server()
.get('/posts', () => db.posts.list())
.get('/posts/:id', (ctx) => db.posts.find(ctx.url.params.id))
.post('/posts', requireUser, (ctx) => db.posts.create(ctx.body));A request matches at most one route. Routes for a method are tried in the order they were registered, and the first whose path matches wins, its middleware runs, and no other route for that method is ever tried, even a more specific one registered later. This is the one thing to get used to coming from Express: register specific paths before catch-alls.
export default server()
.get('/users/:id', (ctx) => `user ${ctx.url.params.id}`)
.get('/users/me', () => 'never reached, /users/:id already matched "me"');Path
The path is one of four shapes:
.get('/info', ...) // exact match
.get('/posts/:id', ...) // a named parameter
.get('/posts/:id(number)', ...) // a typed parameter
.get('/files/*', ...) // a wildcard
.get(...) // omitted: same as '*', matches everythingMatching is exact and doesn't do what Express does by default: /info matches only /info, never /info/extra or /. A trailing slash is ignored either side (/info and /info/ are the same route). To match a whole subtree, be explicit with a wildcard.
Parameters
A segment starting with : captures that part of the path into ctx.url.params:
export default server()
.get('/posts/:id', (ctx) => {
console.log(ctx.url.params.id); // a string
});The value is URL-decoded, so /posts/John%20Doe gives params.id === "John Doe". It's typed too: writing .get('/posts/:id', ...) gives ctx.url.params.id the type string in TypeScript, inferred straight from the path string, no generic to fill in.
Typed parameters. Add (number) or (date) to cast the value:
export default server()
.get('/posts/:id(number)', (ctx) => {
typeof ctx.url.params.id; // "number"
})
.get('/calendar/:day(date)', (ctx) => {
ctx.url.params.day instanceof Date; // true
});A value that fails to cast doesn't 404, the route still matches, and the parameter is undefined instead (TypeScript still types it as number/Date, so guard before trusting it):
.get('/posts/:id(number)', (ctx) => {
if (ctx.url.params.id === undefined) return 400; // "abc" failed to cast
return db.posts.find(ctx.url.params.id);
});This is deliberate: matching is about the shape of the path, never the value inside it, the same way /posts/:id matches regardless of what :id contains. A bad id is a 400 on a real route, not a 404 for a route that doesn't exist, and only the handler can say why it was bad. The original string isn't kept anywhere once the cast fails, so read it from ctx.url.pathname if you need it for an error message.
Optional parameters. A trailing ? matches with or without that segment:
.get('/posts/:id?', (ctx) => {
ctx.url.params.id; // string, or undefined at "/posts"
});Wildcards
* matches one or more remaining segments, collected as an array on ctx.url.params['*']:
export default server()
.get('/files/*', (ctx) => {
ctx.url.params['*']; // e.g. ["docs", "readme.md"] for /files/docs/readme.md
return `Requested: ${ctx.url.pathname}`;
});A bare .get(path) with no path, or *, matches everything, which is how .use() and static-file middleware answer requests with no matching route.
See also
- Parameters on
ctx.url: reading params and the query string together. - Options: the object that can follow the path.
Options
An options object between the path and the middleware overrides the server's defaults for that one route (local wins):
server({ cache: false })
// cache this route for an hour
.get('/posts', { cache: '1h' }, () => db.posts.list())
// read the raw bytes for a webhook signature
.post('/hook', { parser: 'raw' }, (ctx) => verify(ctx.body));Only a handful of options make sense per route, not the full server() options:
| Option | Type | Description |
|---|---|---|
parser | 'parse' | 'raw' | 'stream' | How the request body is read, see parser |
body | Schema | Validates ctx.body, see Validation |
query | Schema | Validates ctx.url.query |
params | Schema | Validates ctx.url.params |
response | Schema | Validates what the route returns |
cache | duration | number | false | Cache-Control for this route, see cache |
uploads | path | Bucket | { bucket, ...limits } | false | Where this route's files go, replacing the root uploads wholesale; false skips files here |
schema | { tags?, title?, description? } | false | Spec metadata for the OpenAPI docs, or false to hide the route from the spec; inert at request time |
Validation
The body, query, params and response options take a Standard Schema: any schema from zod, valibot, arktype and the rest works, with nothing to configure. The request parts are validated before any of the route's middleware run, and the validated output replaces the original value, so coercions and transforms apply and, in TypeScript, ctx.body and ctx.url.query are typed from the schema:
import { z } from 'zod';
export default server()
.post('/users', {
body: z.object({ name: z.string(), age: z.coerce.number() }),
}, (ctx) => {
// ctx.body is validated and typed: { name: string, age: number }
return db.users.create(ctx.body);
})
.get('/users', {
query: z.object({ page: z.coerce.number().default(1) }),
}, (ctx) => db.users.list({ page: ctx.url.query.page }));A failing request schema responds 422 with a generic message (Invalid request body), never the field names or schema details. A failing response schema is a server bug, so it responds 500 Server Error. Both throw a ValidationError carrying the source and the schema's issues, which your onError can log or turn into a richer response.
The response schema checks plain object and array returns, the JSON payload it describes; returning a status code, a Response, a file or a stream skips it. And since a body schema needs a parsed body, combining it with parser: 'raw' or 'stream' throws at startup.
A params schema and the path's own typed parameters (/users/:id(number)) are two independent mechanisms: the path type casts and keeps matching, the schema validates and can reject with a 422. Use the path form for simple casts, the schema when you need real rules; with both, the schema receives the already-cast values.
The same schemas also drive the OpenAPI spec when that option is enabled.
See also
- Path: what can go in the path string, parameters and wildcards.
.use(): middleware that runs on every route, instead of one.
Middleware
A middleware is a plain (ctx) => ... function: it receives the context and optionally returns a response. Returning nothing (undefined, null, false) lets the next one run; returning anything else stops the chain right there and sends that as the response:
const requireUser = (ctx) => {
if (!ctx.user) return 401; // stop here, respond 401
// returning nothing: fall through to the next middleware or the handler
};The same function shape goes in three places:
Globally, with .use(): runs on every request registered after it, including requests no route matches:
export default server()
.use((ctx) => console.log(ctx.method, ctx.url.pathname))
.get('/posts', () => db.posts.list());On a route, before its handler: chain as many as needed, .get(path, auth, validate, handler), each running in order until one responds. The last one is usually the handler that returns the response:
export default server()
.get('/public', () => 'Anyone can see this')
.get('/private', requireUser, () => 'Logged-in users only');On a WebSocket event, with .socket(): the same shape for open, message and close events, with ctx.socket/ctx.sockets set and ctx.body carrying the message. Socket handlers run outside the HTTP chain, so .use() middleware doesn't apply to them:
export default server()
.socket('message', (ctx) => ctx.socket.send(`Echo: ${ctx.body}`));See also
.use(): ordering and scoping rules for global middleware.- Middleware in Depth: how the three reduce to one model.
.use()
Registers a middleware globally, so it runs on every request, before the matched route's own middleware. It takes no path, functions and whole routers only:
export default server()
.use(logger) // runs on every request
.use(otherRouter) // merge another router's routes in
.get('/', () => 'Hello');The same chaining and short-circuit rules from Middleware apply: a global middleware that returns nothing lets the next one run, and one that returns something ends the chain there, response sent:
export default server()
.use(requireUser)
.get('/private', () => 'only reached with a user');Applies to routes registered after it, in the same chain, top to bottom, same as every step in the chain. Reordering .use() after a route it was meant to guard is the one mistake to watch for.
Scoping to some paths
.use() itself has no path, so scoping it to part of your app is one of three patterns:
1. Check the path inside the middleware:
const requireAdmin = (ctx) => {
if (!ctx.url.pathname.startsWith('/admin/')) return; // not our concern
if (!ctx.user) return 401;
};
export default server()
.use(requireAdmin)
.get('/admin/settings', settingsReply);2. Put it on its own router, so it only applies to that router's routes:
const admin = router()
.use(requireAuth)
.get('/admin/settings', settingsReply)
.get('/admin/users', usersReply);
export default server().use(admin);3. Repeat it on each route:
export default server()
.get('/admin/settings', requireAuth, settingsReply)
.get('/admin/users', requireAuth, usersReply);Prefix layering like Express's .get('/admin/*', auth).get('/admin/settings', ...) doesn't work here, once /admin/* matches a request, /admin/settings is never reached (the first match wins, see above). Use one of the three patterns above instead.
Answering unmatched requests
When no route matches at all, the global .use() middleware still runs, which is how built-in static file serving answers requests that are not routes:
export default server()
.use((ctx) => {
if (ctx.url.pathname === '/robots.txt') return 'User-agent: *';
})
.get('/', () => 'Home');See also
- Middleware: the chaining and return-value rules
.use()follows. - The first match wins, above:
.use()middleware run ahead of whichever route ends up matching.
.get()
Reads data. No request body.
export default server()
.get('/posts', () => db.posts.list())
.get('/posts/:id', (ctx) => db.posts.find(ctx.url.params.id));Every method below shares this exact shape, .METHOD(path?, options?, ...middleware), described above and in Path. What differs between methods is HTTP semantics: whether a body is expected, and what the method conventionally means. Only those differences are called out again below.
.post()
Creates a resource, or anything that isn't idempotent. Accepts a body:
.post('/posts', (ctx) => db.posts.create(ctx.body)).put()
Replaces a resource with new data. Accepts a body:
.put('/posts/:id', (ctx) => db.posts.replace(ctx.url.params.id, ctx.body)).patch()
Applies a partial update to a resource. Accepts a body:
.patch('/posts/:id', (ctx) => db.posts.update(ctx.url.params.id, ctx.body)).delete()
Deletes a resource. No request body:
.delete('/posts/:id', (ctx) => db.posts.remove(ctx.url.params.id)).head()
Every .get() route (and public asset) already answers HEAD requests: the GET handler runs in full and the body is dropped, so the status and headers (Content-Type, cache validators, ...) match the GET exactly. Register .head() only to answer differently, it takes precedence over the GET fallback:
.head('/posts/:id', async (ctx) => (await db.posts.exists(ctx.url.params.id)) ? 200 : 404).options()
Describes what a route accepts, most often used for CORS preflight (which cors already answers for you). No request body:
.options('/posts', () => headers('allow', 'GET,POST').send()).socket()
WebSocket connections. Unlike every method above, it has no path, no ctx.url, and no .use() middleware: it's registered by lifecycle event (open, message, close), and dispatched separately from HTTP requests entirely.
import server, { file } from '@server/next';
export default server()
.get('/', () => file('./index.html'))
.socket('open', (ctx) => {
ctx.socket.send('Welcome!');
})
.socket('message', (ctx) => {
// ctx.body is what this client sent
for (const socket of ctx.sockets) socket.send(ctx.body);
})
.socket('close', (ctx) => {
console.log('a client disconnected');
});The socket ctx is a different, smaller shape than the HTTP one: ctx.socket (this connection), ctx.sockets (every open connection, for broadcasting), ctx.body (the message, on 'message' only), and ctx.user (resolved from the same auth as HTTP routes, if auth is configured). There's no ctx.url, no return-value handling, since there's no HTTP response to send, a socket handler communicates by calling .send() as a side effect.
Any browser connecting with the standard WebSocket API upgrades successfully, on any path, as long as at least one .socket() handler is registered; there's no per-path scoping the way HTTP routes have one.
See also
ctx.socketandctx.sockets: the full reference for what's available in a socket handler.- Authenticated sockets:
ctx.userresolved at the handshake. - Real-time chat: a walkthrough building a broadcast chat with
.socket().
router()
A router() is a standalone chain of routes with no server attached: no port, no options, nothing listening. Its only job is to be merged into a real server (or another router) with .use():
// routes/users.js
import { router } from '@server/next';
export default router()
.get('/users', () => db.users.list())
.get('/users/:id', (ctx) => db.users.find(ctx.url.params.id))
.post('/users', (ctx) => db.users.create(ctx.body));// index.js
import server from '@server/next';
import usersRouter from './routes/users.js';
export default server()
.use(usersRouter)
.get('/', () => 'Home');Reach for it once a single file gets unwieldy, typically somewhere past a handful of routes: split by resource (routes/users.js, routes/posts.js, ...) and .use() each one into the main server. Every method covered above (.get(), .post(), .use(), .socket(), ...) works identically on a router() and on server(), since server() is a router with a port and options attached.
Routers are merged at the root, so write the full path on every route, /users/:id inside routes/users.js, not a relative /:id. There's no path prefixing: if you want /api/users, write /api/users in the router itself.
In TypeScript, router() takes the same generic as server(), so a sub-router's handlers keep ctx.user typed (see Typing ctx):
export default router<{ user: User }>()
.get('/users/:id', (ctx) => {
if (ctx.user?.role !== 'admin') return 403;
return db.users.find(ctx.url.params.id);
});Keeping handlers inline on a router is also what keeps them typed for free: params infer from the path and ctx.body from the route's schemas, nothing to declare. A handler extracted to a standalone function has to declare those types itself, so prefer splitting by router, not by loose handler functions.
See also
.use(): how a router is merged in, and how to scope middleware to just that router's routes.