Context

Every middleware receives a ctx object with everything about the request:

export default server()
  .use(ctx => { ... })
  .get('/hello', (ctx) => { ... });
FieldDescription
ctx.bodyThe parsed body, a Buffer or a stream, per the parser option.
ctx.cookiesThe parsed request cookies as a plain object.
ctx.headersThe request headers as a plain object, lowercased keys.
ctx.ipThe client IP, proxy-aware through trustProxy.
ctx.methodThe HTTP method, lowercased ('get', 'post', ...).
ctx.optionsThe resolved server settings.
ctx.platformInfo about the runtime (runtime, production, provider).
ctx.signalAn AbortSignal that fires if the client disconnects mid-request.
ctx.socketThe active WebSocket connection, inside .socket() handlers.
ctx.socketsEvery open WebSocket connection, for broadcasting.
ctx.timeA Server-Timing helper to measure segments of a request.
ctx.urlThe request URL, extended with params and query.
ctx.userThe authenticated user, when auth is configured.
ctx.authThe credential itself: when it was issued, and by whom.

Your middleware can add fields of its own; see Your own fields. To type ctx.user and the rest in TypeScript, see Typing ctx.

ctx.body

The request body. By default it is parsed by content-type; the parser option controls how it is read.

.post('/users', (ctx) => {
  console.log(ctx.body.name);   // 'Francisco'
  console.log(ctx.body.email);  // '[email protected]'
});

With the default parse mode:

  • For application/json requests, it is the parsed JSON value.
  • For application/x-www-form-urlencoded or multipart/form-data text fields, it is a plain object of field values. A repeated field name collects into an array (HTML's name="tags[]" works too; the [] is stripped).
  • For file uploads, the field value is an UploadedFile object { name, path, type, size } (or an array of them for a repeated field name). Files stream to uploads as they arrive, so their bytes are never all in memory. See File handling.
  • For a raw body of any other content type (image/png, video/mp4, application/octet-stream, anything unrecognised), the whole body is streamed to uploads as one UploadedFile. With no uploads configured the request is refused, since there is nowhere to put it; uploads: false gives you the raw Buffer instead.
  • undefined when the request carries no body, as is typical for GET or DELETE.

The other parser modes give ctx.body a different shape:

parserctx.body
parse (default)the parsed object / file references above
rawa Buffer of the unparsed bytes
streamthe request's web ReadableStream, unread

A route with a body schema validates ctx.body before your middleware run, so the handler sees the schema's output (transforms and coercions included).

ctx.cookies

The parsed request cookies as a plain object:

.get('/', (ctx) => {
  console.log(ctx.cookies.token);  // 'abc123'
});

To set cookies in the response, use the cookies() reply helper.

ctx.headers

The request headers as a plain object, with lowercased keys:

.get('/', (ctx) => {
  // 'application/json'
  console.log(ctx.headers['content-type']);
  // 'Bearer ...'
  console.log(ctx.headers['authorization']);
});

To set headers in the response, use the headers() reply helper.

ctx.ip

The IP address of the client making the request:

.get('/', (ctx) => {
  console.log(ctx.ip);  // '203.0.113.5'
});

Behind a proxy, the real client IP is read from forwarding headers, governed by the security.trustProxy option (on by default). Set security: { trustProxy: false } when clients connect directly.

ctx.method

The HTTP method of the request, lowercased:

.get('/example', (ctx) => {
  console.log(ctx.method);  // 'get'
});

Possible values: 'get', 'post', 'put', 'patch', 'delete', 'head', 'options'.

ctx.options

The resolved server settings (the processed version of the options passed to server()):

.get('/', (ctx) => {
  console.log(ctx.options.port);   // 3000
});

Mainly useful in middleware that needs to inspect server configuration.

ctx.platform

Information about the runtime environment:

.get('/', (ctx) => {
  // 'node' | 'bun' | 'deno' | null
  console.log(ctx.platform.runtime);
  // true in production (NODE_ENV, or the platform's own signal)
  console.log(ctx.platform.production);
  // 'netlify' | null
  console.log(ctx.platform.provider);
});

ctx.signal

An AbortSignal that fires if the client disconnects before the response is sent:

.get('/summary', async (ctx) => {
  // If the client is gone, cancel the slow upstream call too
  const res = await fetch(SLOW_API, { signal: ctx.signal });
  return res.json();
});

Pass it to anything cancelable (fetch, database drivers, your own ctx.signal.addEventListener('abort', ...)) so expensive work stops when nobody is waiting for the result. This matters most for slow handlers, proxied requests and long-lived streamed responses.

On a normal request it never fires: it stays un-aborted while the handler runs, and the connection closing after the response is complete doesn't trigger it.

ctx.socket

The WebSocket connection for the current event, available inside .socket() handlers (open, message, close). It's the single client this event is about; call ctx.socket.send(data) to reply to just them.

export default server()
  .get('/', () => file('./index.html'))
  .socket('open', (ctx) => {
    ctx.socket.send('Welcome!');
  })
  .socket('message', (ctx) => {
    // ctx.body is what this client sent
    ctx.socket.send(`You said: ${ctx.body}`);
  });

send() accepts a string or binary data. On a message event the incoming payload is on ctx.body. WebSockets work on both Node and Bun; see the WebSockets guide for the full picture.

ctx.sockets

Every open WebSocket connection, as an array, available inside .socket() handlers. A connection is added when it opens and removed when it closes, so iterating ctx.sockets is how you broadcast to everyone.

.socket('message', (ctx) => {
  // Relay this message to every connected client
  for (const socket of ctx.sockets) {
    socket.send(ctx.body);
  }
});

To broadcast to everyone except the sender, skip ctx.socket:

.socket('message', (ctx) => {
  for (const socket of ctx.sockets) {
    if (socket === ctx.socket) continue;
    socket.send(ctx.body);
  }
});

ctx.time

A timing helper for Server-Timing. Call ctx.time('label') to mark a point; the time spent in each labeled segment (since the previous mark) is sent back in the Server-Timing response header, which browsers show in their Network panel. No header is added if you never call it.

.get('/report', async (ctx) => {
  const rows = await db.query('...');
  // time from start of request to here
  ctx.time('query');
  const html = render(rows);
  // time since the previous mark
  ctx.time('render');
  return html;
});
// Server-Timing: query;dur=42, render;dur=8

ctx.url

The full URL of the request, extended with params and query:

.get('/users/:id', (ctx) => {
  console.log(ctx.url.pathname);     // '/users/42'
  console.log(ctx.url.params.id);    // '42'
  console.log(ctx.url.query.page);   // '2'  (from ?page=2)
  console.log(ctx.url.hostname);     // 'localhost'
});
  • ctx.url.params: URL path parameters, e.g. /users/:idctx.url.params.id. Types can be inferred; see Parameters.
  • ctx.url.query: Parsed query string as a plain object, e.g. ?page=2&sort=asc{ page: '2', sort: 'asc' }. A repeated key keeps the last value (unlike body fields, which collect into arrays).
  • All standard URL properties are available (pathname, hostname, href, origin, etc.).
  • Use ctx.url.protocol to check the scheme ('https:' for a secure request).

ctx.user

Requires the auth option to be configured.

The authenticated user, populated when a user is logged in. undefined when not authenticated. Available on HTTP routes and, resolved from the handshake, inside .socket() handlers too.

.get('/profile', (ctx) => {
  if (!ctx.user) return 401;
  return { name: ctx.user.name, email: ctx.user.email };
});

Its shape is yours: it is whatever your getUser returns, or the profile when there is no database. Nothing is added to it, so there are no framework fields to work around.

That also means it is typed from your own auth, with no generic to declare:

server({ auth: { providers: 'github', onLogin, getUser: (id) => db.users.find(id) } })
  .get('/profile', (ctx) => {
    if (!ctx.user) return 401;
    return ctx.user.name;      // typed from getUser's return
  });

For a handler in another file there is no server() call to infer from, so declare it there instead (see Typing ctx).

Which provider someone used, and how this request authenticated, are on ctx.auth rather than here: they describe the login, not the person.

ctx.auth

Requires the auth option to be configured.

What the credential itself asserts, read with no lookup behind it:

ctx.auth   // { issuedAt: Date, expiresAt?: Date, strategy?, provider? }
.post('/account/delete', (ctx) => {
  if (!ctx.user) return 401;
  // Ask them to sign in again before something irreversible
  const age = Date.now() - ctx.auth.issuedAt.getTime();
  if (age > 15 * 60 * 1000) return 403;
  return db.users.delete(ctx.user.id);
});
  • issuedAt: when this credential was minted
  • expiresAt: when it stops being accepted, if it expires
  • provider: who vouched for them ('github', or the issuer for a token minted elsewhere)
  • strategy: how this request authenticated, which matters when several are accepted

undefined when there is no credential, and also when auth is a plain function or a third-party library, since neither leaves a credential of ours to read.

Typing ctx

Most of ctx is typed by inference: route params come from the path string, and ctx.body / ctx.url.query / ctx.url.params from the route's schemas. What inference can't know, you declare once with a generic naming the fields of ctx you use:

type User = { id: string; email: string; role: 'admin' | 'user' };

export default server<{ user: User }>()
  .get('/me', (ctx) => ctx.user?.email ?? 401)
  .get('/admin', (ctx) => (ctx.user?.role === 'admin' ? 'welcome' : 403));

The same generic works on router(), so a sub-router's inline handlers stay typed, and on Context / Middleware directly, for a function defined away from its route. Name only the slice the function touches; params, query and body take a plain type or a schema:

import type { Context, Middleware } from '@server/next';

const requireAdmin: Middleware<{ user: User }> = (ctx) => {
  if (ctx.user?.role !== 'admin') return 403;
};

const updatePost = (
  ctx: Context<{ params: { id: string }; body: typeof PostSchema }>,
) => db.posts.update(ctx.url.params.id, ctx.body);

export default server<{ user: User }>()
  .put('/posts/:id', { body: PostSchema }, requireAdmin, updatePost);

Declared fields are typed exactly; the fields a declaration leaves out stay open, so functions declaring different slices compose on the same route. Prefer inline handlers on a router() where you can: they need no declarations at all.

Your own fields

Middleware often puts something on ctx for later handlers to read:

export default server()
  .use(async (ctx) => {
    ctx.project = await resolveProject(ctx);
  })
  .get('/blog', (ctx) => render(ctx.project));

That works as-is in JavaScript. In TypeScript, tell it what you added by augmenting ContextExtension once, anywhere in your project:

import type { Project } from './types';

declare module '@server/next' {
  interface ContextExtension {
    project?: Project;
  }
}

ctx.project is now typed in every handler, and a typo like ctx.projekt is an error instead of any. The alternative, annotating a handler (ctx: any), would throw away the typing of everything else on ctx too.

Keep the fields optional. The augmentation applies to every ctx in your project, including requests that never went through the middleware that sets them, so ctx.project!.name compiles but can still be undefined at runtime. Check it, or narrow it in the handler:

.get('/blog', (ctx) => {
  if (!ctx.project) return 404;
  return render(ctx.project);
});