Concepts

One idea makes the whole router predictable: global is just a default that gets flattened onto each route. .use() and the server options aren't a separate runtime layer; they're sugar for repeating something on every route defined after them. Once you hold that model, you can predict how middleware, options, and routers compose without memorizing cases.

Middleware in Depth

The Router docs show the three places a middleware goes: .use(), a route, a socket event. Under the model, the first two are the same thing:

export default server()
  .use(A)
  .get('/b', B)
  .get('/c', C);

is the same as writing:

export default server()
  .get('/b', A, B)
  .get('/c', A, C);

This isn't just conceptually true, it's how it works: when a route is registered, the current .use() middleware is baked into that route's own chain, and at request time a matched route runs its flat list, with no second global pass. Merging a router() works the same way, so a merged route is indistinguishable from one written inline.

Two consequences fall out, and both are features:

  • .use() only affects routes defined after it. It prepends to the subsequent routes, not the earlier ones, so order is meaningful and local.
  • There's no hidden global stack. What a route runs is exactly its own chain; you could print it.

The built-ins use the same mechanism, not a privileged one. server() registers its own middleware at construction (timing, CORS preflight, static assets, and auth's user loading), which is why they sit before your functions in every chain, and why ctx.user is already loaded by the time your first middleware runs. The one framework step that is not a middleware is validation: a route's schemas run after the body is read and before its chain, so even your guards only ever see validated values.

The two deliberate departures from "one flat chain per route":

  • Unmatched requests run the global .use() middleware on their own, which is how static files answer requests that are not routes, and how a catch-all .use() can respond to anything.
  • Socket events run outside the HTTP chain entirely (.use() middleware never fires for them), since an event on a long-lived connection isn't a request; auth still resolves at the handshake, so ctx.user works there too.

Options flatten too

A global option is the default each route inherits, and a route can restate it to differ (local wins, replacing the value wholesale rather than merging):

server({ cache: false })            // default for every route
  .get('/a', () => ...)             // cache: false (inherited)
  .get('/b', { cache: '1h' }, ...); // cache: '1h' (overridden)

This is real for parser, cache and uploads. The validation schemas (body, query, params, response) are the same model taken one step further: they're per-route values with no global form at all, since one schema could never describe every route. The model doesn't require every route value to have a root form, only that root values reduce to route values.

What stays at the root

Some options are root-only on purpose, and the line is worth knowing:

  • Policy, not behavior. security (headers, trustProxy, traversalProtection, maxBodySize) is an app-wide guarantee. A cap or header that silently varied per route would be exactly the kind of hidden layer this model exists to avoid: auditing "what does this app enforce" should never require reading every route.
  • Wiring, not requests. auth, port and log describe how the app is assembled (providers, callbacks, the listener), not how one request behaves. Per-route authorization is a middleware concern, a guard in the chain, not an option.

cors also stays at the root: a route that needs its own cross-origin policy can set the headers itself, with headers() plus an .options() route for the preflight.

So the invariant, stated once: every per-request behavior option reduces to a value on each route (flattened defaults, local wins), while policy and wiring stay at the root, visible in one place.

Why it matters

If middleware and behavior config always reduce to "a value on each route", then the router has one rule instead of two (a global layer plus a route layer), every request is explained by its route alone, and everything is inspectable per route, which is what powers the OpenAPI generation and makes debugging a matter of looking at one chain.