Context
Every middleware receives a ctx object with everything about the request:
export default server()
.use(ctx => { ... })
.get('/hello', (ctx) => { ... });| Field | Description |
|---|---|
ctx.body | The parsed body, a Buffer or a stream, per the parser option. |
ctx.cookies | The parsed request cookies as a plain object. |
ctx.headers | The request headers as a plain object, lowercased keys. |
ctx.ip | The client IP, proxy-aware through trustProxy. |
ctx.method | The HTTP method, lowercased ('get', 'post', ...). |
ctx.options | The resolved server settings. |
ctx.platform | Info about the runtime (runtime, production, provider). |
ctx.signal | An AbortSignal that fires if the client disconnects mid-request. |
ctx.socket | The active WebSocket connection, inside .socket() handlers. |
ctx.sockets | Every open WebSocket connection, for broadcasting. |
ctx.time | A Server-Timing helper to measure segments of a request. |
ctx.url | The request URL, extended with params and query. |
ctx.user | The authenticated user, when auth is configured. |
ctx.auth | The 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/jsonrequests, it is the parsed JSON value. - For
application/x-www-form-urlencodedormultipart/form-datatext fields, it is a plain object of field values. A repeated field name collects into an array (HTML'sname="tags[]"works too; the[]is stripped). - For file uploads, the field value is an
UploadedFileobject{ name, path, type, size }(or an array of them for a repeated field name). Files stream touploadsas 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 touploadsas oneUploadedFile. With nouploadsconfigured the request is refused, since there is nowhere to put it;uploads: falsegives you the rawBufferinstead. undefinedwhen the request carries no body, as is typical forGETorDELETE.
The other parser modes give ctx.body a different shape:
parser | ctx.body |
|---|---|
parse (default) | the parsed object / file references above |
raw | a Buffer of the unparsed bytes |
stream | the 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=8ctx.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/:id→ctx.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
URLproperties are available (pathname,hostname,href,origin, etc.). - Use
ctx.url.protocolto check the scheme ('https:'for a secure request).
ctx.user
Requires the
authoption 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
authoption 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 mintedexpiresAt: when it stops being accepted, if it expiresprovider: 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);
});