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 mostly use the same mechanism, not a privileged one. server() registers its own middleware at construction (timing, CORS preflight, static assets), which is why they sit before your functions in every chain. Two steps on the way in are not middleware. ctx.user is resolved before the body is read, early enough that uploads.validate can refuse a request before a file is written; your own middleware see it already loaded. Then validation runs a route's schemas after the body and before its chain, so even your guards only ever see validated values. On the way out, the shared response headers and the onResponse hook sit outside the chain too.

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 plain code in the route, 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.

Authorization

Authentication says who someone is; authorization is what they may do. The auth option only fills ctx.user and never rejects a request, so every route decides for itself, with plain if statements:

export default server({ auth })
  .get('/public', () => 'anyone')

  .get('/account', (ctx) => {
    if (!ctx.user) return 401;
    return `Hello ${ctx.user.email}`;
  })

  .get('/admin', (ctx) => {
    if (!ctx.user) return 401;
    if (ctx.user.role !== 'admin') return 403;
    return db.reports.all();
  })

  .get('/files/:id', async (ctx) => {
    if (!ctx.user) return 401;
    const file = await db.files.find(ctx.url.params.id);
    if (!file) return 404;
    if (file.userId !== ctx.user.id) return 403;
    return file;
  });

Keeping the rules in the handler puts each one next to the data it protects, and the checks narrow ctx.user's type, so after if (!ctx.user) return 401 TypeScript knows it is there.

401 or 403

401 says "I do not know who you are", 403 says "I do and you may not". Sending 403 to a signed-out visitor tells them a page exists that they cannot see, and sending 401 to a signed-in one makes most frontends bounce them through a pointless login.

Roles

A provider's profile has no role, so keep it on your own users and return it from getUser. Whatever getUser returns is ctx.user, role included:

const auth = {
  providers: 'github',
  onLogin: async (profile) => {
    const user = await db.users.upsert({ email: profile.email });
    return user.id;
  },
  getUser: (id) => db.users.find(id),   // { id, email, role: 'admin' | 'editor' }
};

export default server({ auth })
  .get('/admin', (ctx) => {
    if (!ctx.user) return 401;
    if (ctx.user.role !== 'admin') return 403;
    return db.reports.all();
  });

Under the default session strategy, getUser runs on every request, so changing someone's role applies on their next request.

Permissions

For finer rules, turn the role into a list of permissions once, at login, with toPublicUser. Under the cookie strategy that list is signed into the cookie, so later requests check it without touching the database:

const PERMISSIONS = {
  admin: ['posts:read', 'posts:write', 'users:manage'],
  editor: ['posts:read', 'posts:write'],
  reader: ['posts:read'],
};

const auth = {
  providers: 'github',
  strategy: 'cookie',
  onLogin: async (profile) => {
    const user = await db.users.upsert({ email: profile.email });
    return user.id;
  },
  getUser: (id) => db.users.find(id),
  toPublicUser: (user) => ({
    id: user.id,
    email: user.email,
    permissions: PERMISSIONS[user.role] ?? [],
  }),
};

export default server({ auth })
  .post('/posts', (ctx) => {
    if (!ctx.user) return 401;
    if (!ctx.user.permissions.includes('posts:write')) return 403;
    return db.posts.create(ctx.body);
  });

The list is fixed when they sign in, so a changed role applies the next time they do. To have it apply at once, compute the list in getUser under the session strategy instead, where it is rebuilt on every request.

Ownership

Ownership needs no auth hook: record who created each row, and compare that to ctx.user.id when someone reads or changes it. Scope lists by the owner in the query itself, so nothing else is ever loaded:

export default server({ auth })
  .post('/notes', (ctx) => {
    if (!ctx.user) return 401;
    return db.notes.create({ ...ctx.body, userId: ctx.user.id });
  })

  .get('/notes', (ctx) => {
    if (!ctx.user) return 401;
    return db.notes.list({ userId: ctx.user.id });   // only their own
  })

  .delete('/notes/:id', async (ctx) => {
    if (!ctx.user) return 401;
    const note = await db.notes.find(ctx.url.params.id);
    if (!note || note.userId !== ctx.user.id) return 404;
    await db.notes.delete(note.id);
    return 204;
  });

Someone else's note answers 404 rather than 403, so ids cannot be probed to learn what exists. Use 403 when the row is public knowledge and only the change is refused, like the /files/:id route above.

Combined with a role, an admin can act on anyone's rows:

const canEdit = (user, note) => note.userId === user.id || user.role === 'admin';

.patch('/notes/:id', async (ctx) => {
  if (!ctx.user) return 401;
  const note = await db.notes.find(ctx.url.params.id);
  if (!note) return 404;
  if (!canEdit(ctx.user, note)) return 403;
  return db.notes.update(note.id, ctx.body);
})

ctx.user.id is whatever id your getUser returns, so it matches the userId you stored however people signed in. Without getUser, ctx.user is the provider's profile or a token's claims, where the id is theirs rather than yours (sub for a token), so add getUser before storing ids on your rows.

Repeated checks

For a check shared by many routes, a middleware works, since returning a status stops the chain:

const signedIn = (ctx) => {
  if (!ctx.user) return 401;
};

export default server({ auth })
  .get('/', () => 'anyone')
  .use(signedIn)                        // every route below needs a user
  .get('/account', (ctx) => ctx.user.email)
  .get('/settings', (ctx) => db.settings.find(ctx.user.id));

A middleware cannot narrow the handler's types, so under TypeScript keep the inline if where the handler reads ctx.user. Declaring the type with router<{ user: User }>() says what the user is, not that there is one, so the check stays either way.

Guarding uploads

An upload route needs its check earlier. The body is parsed before the chain runs, so by the time a handler or a middleware looks at ctx.user the file is already stored. Refuse it with uploads.validate instead, which runs before a byte is read, with ctx.user already resolved:

import server, { ServerError } from "@server/next";

const EDITORS = ['alice', 'bob'];

export default server({
  auth,
  uploads: {
    bucket: './uploads',
    validate: (ctx) => {
      if (!EDITORS.includes(ctx.user?.id)) {
        throw new ServerError('NOT_AN_EDITOR', 403, 'Only editors upload covers');
      }
    },
  },
})
  .post('/article/:id/cover', (ctx) => ctx.body.cover);

Return false for a plain 403, or throw as above when the reason is worth saying. See Validation for the size and type limits that sit next to it, and Per-user folders for giving each user their own prefix.

Sensitive actions

Before something irreversible, check how long ago they signed in with ctx.auth, and send them back through the login if it has been too long:

.post('/account/delete', (ctx) => {
  if (!ctx.user) return 401;
  const age = Date.now() - ctx.auth.issuedAt.getTime();
  if (age > 15 * 60 * 1000) return 403;
  return db.users.delete(ctx.user.id);
})