Context
ctx is the one argument every handler, middleware and hook receives.
app.get('/users/:id', (ctx) => { ctx.log.info('looking up', { id: ctx.params.id }) return ctx.db.select().from(users).where(eq(users.id, ctx.params.id))})Two things shape the whole design.
Everything expensive is lazy. A handler that returns a string parses no body, no query string and no cookies, and generates no request id. This is not an aspiration — it is asserted by tests that count reads of the underlying request.
Its type is what you registered. ctx.db exists because you added the database brick; leave
it out and touching it is a compile error, not a crash at 3am.
Request
Section titled “Request”ctx.req
Section titled “ctx.req”The raw web-standard Request. Never
wrapped, always available — anything the framework does not expose, you can reach here.
ctx.params
Section titled “ctx.params”Path parameters from the matched route, as strings.
ctx.params.id // stringctx.params.postId // stringDeclare a params schema and they are typed and coerced instead:
export default route({ params: z.object({ id: z.uuid() }) }, (ctx) => ctx.params.id)A catch-all segment [...key] puts the remainder under that name — ctx.params.key. A bare
* in a programmatically registered route lands under ctx.params['*'].
ctx.path
Section titled “ctx.path”The pathname, e.g. /users/42. Answered from a substring rather than by building a URL,
because routing needs it on every request.
ctx.method
Section titled “ctx.method”The request method, uppercase.
ctx.url
Section titled “ctx.url”The parsed URL, built on first read and
reused. Reach for it when you want the origin, hash or raw searchParams; ctx.path and
ctx.query cover the common cases without constructing one.
ctx.query
Section titled “ctx.query”The query string, parsed lazily, with arrays and nested keys.
// ?tag=a&tag=b&page[size]=20ctx.query.tag // ['a', 'b']ctx.query.page // { size: '20' }Values are strings — a query string has no types. Declare a query schema to coerce them:
export default route( { query: z.object({ page: z.coerce.number().default(1) }) }, (ctx) => ctx.query.page, // number)ctx.header(name)
Section titled “ctx.header(name)”One request header, or undefined. Case-insensitive.
ctx.header('content-type')Returns undefined rather than null, which reads better beside every other optional on the
context.
ctx.accepts(...types)
Section titled “ctx.accepts(...types)”Content negotiation. Returns the best of the offered types for this client’s Accept header, or
undefined when none is acceptable.
switch (ctx.accepts('application/json', 'text/html')) { case 'text/html': return page() default: return data()}Quality values are honoured, and a request with no Accept gets your first offer.
ctx.ip
Section titled “ctx.ip”The client address, or undefined when a Request was dispatched directly rather than served
over a socket.
Body and uploads
Section titled “Body and uploads”ctx.body
Section titled “ctx.body”The parsed body, awaited. Content-type aware: JSON, urlencoded, multipart, text and raw.
const body = await ctx.bodyWith a body schema it is validated and typed, and you do not await it:
export default route( { body: z.object({ title: z.string().min(1) }) }, (ctx) => ctx.body.title, // string, already validated)Uploads arrive as web File objects,
streamed rather than buffered, with size and MIME limits applied before your handler runs.
export default route( { body: z.object({ file: z.file().max(5_000_000) }) }, (ctx) => ctx.storage.upload(`uploads/${ctx.body.file.name}`, ctx.body.file),)ctx.rawBody
Section titled “ctx.rawBody”The unparsed bytes, as an ArrayBuffer. For webhooks that sign the raw payload — re-serialising
parsed JSON produces different bytes and a signature that never verifies.
const raw = await ctx.rawBodyconst valid = verifySignature(raw, ctx.header('stripe-signature'))Reading it does not prevent reading ctx.body afterwards.
Cookies
Section titled “Cookies”ctx.cookies
Section titled “ctx.cookies”Parsed on first touch.
ctx.cookies.get('session') // string | undefinedctx.cookies.has('session') // booleanctx.cookies.all() // Record<string, string>ctx.cookies.set('session', token, { maxAge: 3600 })ctx.cookies.delete('session')Cookies set through ctx.cookies are httpOnly and SameSite=Lax by default, and Secure
outside development — so shipping to production does not quietly downgrade every session cookie.
Signing is available without a separate package.
Credentials
Section titled “Credentials”ctx.token
Section titled “ctx.token”The credential, captured from Authorization, then the token cookie, then the
?access_token= query parameter — in that precedence. Present with or without an auth brick
installed.
if (ctx.token) { /* … */ }Both fallbacks are configurable, and the query one can be switched off entirely:
createApp({ token: { cookie: 'session', query: null } })ctx.tokenSource
Section titled “ctx.tokenSource”Where the token came from: 'header', 'cookie' or 'query'.
// Refuse a credential that arrived somewhere it could be logged.if (ctx.tokenSource === 'query') throw new Unauthorized('Send the token as a header.')ctx.tokenScheme
Section titled “ctx.tokenScheme”The Authorization scheme, when the credential came from a header — Bearer, Basic, and so on.
ctx.basicAuth
Section titled “ctx.basicAuth”Decoded Basic credentials, when that is what arrived.
const creds = ctx.basicAuth // { username, password } | undefinedctx.user
Section titled “ctx.user”Present when the auth brick is registered. Identity | null on an open
route; narrowed to non-null inside a route declaring auth: true or a policy name.
export default route({ auth: true }, (ctx) => ctx.user.id) // no null check neededObservability
Section titled “Observability”ctx.id
Section titled “ctx.id”A stable id for this request. Adopted from x-request-id when a proxy already set one, so a
trace survives the hop; generated otherwise.
Generated lazily — an app that never logs never pays for a UUID — and echoed on the response only when something actually read it.
ctx.log
Section titled “ctx.log”A Logger with requestId bound to every line, derived on first read.
ctx.log.info('charging card', { amount: 1200 })ctx.log.error('gateway refused', { code })Every error response also carries the request id, so a user’s screenshot is enough to find the log line.
Response
Section titled “Response”ctx.status
Section titled “ctx.status”Set it to choose the status. Left unset, the returned value decides: null becomes 204,
everything else 200.
ctx.status = 201ctx.set(name, value) · ctx.append(name, value)
Section titled “ctx.set(name, value) · ctx.append(name, value)”Set or append a response header. Both chainable. Use append for headers that legitimately
repeat, such as set-cookie.
ctx.set('cache-control', 'no-store').set('x-thing', '1')ctx.redirect(location, status?)
Section titled “ctx.redirect(location, status?)”Builds a redirect response. 302 unless you say otherwise.
app.get('/old', (ctx) => ctx.redirect('/new', 301))ctx.respond(value)
Section titled “ctx.respond(value)”Coerces a value into a Response using this context’s status and headers. The server calls it
for you; it is exposed because middleware occasionally needs to build the response early.
ctx.responseHeaders
Section titled “ctx.responseHeaders”The headers staged so far, or undefined if none have been set. Mostly of interest to
middleware inspecting what a handler did.
What bricks add
Section titled “What bricks add”Everything above is always there. Each brick you register adds its own, typed:
| Brick | Adds |
|---|---|
db |
ctx.db — the native ORM client |
auth |
ctx.user, ctx.auth |
storage |
ctx.storage |
queue |
ctx.queue |
mail |
ctx.mail |
Writing your own is about fifty lines.
Outside a request
Section titled “Outside a request”app.service('db') reaches the same configured value with no request in sight — for a migration
script, a seed, or oven worker. See App.