Skip to content

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.

The raw web-standard Request. Never wrapped, always available — anything the framework does not expose, you can reach here.

Path parameters from the matched route, as strings.

src/routes/users/[id]/posts/[postId].get.ts
ctx.params.id // string
ctx.params.postId // string

Declare 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['*'].

The pathname, e.g. /users/42. Answered from a substring rather than by building a URL, because routing needs it on every request.

The request method, uppercase.

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.

The query string, parsed lazily, with arrays and nested keys.

// ?tag=a&tag=b&page[size]=20
ctx.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
)

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.

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.

The client address, or undefined when a Request was dispatched directly rather than served over a socket.

The parsed body, awaited. Content-type aware: JSON, urlencoded, multipart, text and raw.

const body = await ctx.body

With 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),
)

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.rawBody
const valid = verifySignature(raw, ctx.header('stripe-signature'))

Reading it does not prevent reading ctx.body afterwards.

Parsed on first touch.

ctx.cookies.get('session') // string | undefined
ctx.cookies.has('session') // boolean
ctx.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.

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 } })

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.')

The Authorization scheme, when the credential came from a header — Bearer, Basic, and so on.

Decoded Basic credentials, when that is what arrived.

const creds = ctx.basicAuth // { username, password } | undefined

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 needed

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.

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.

Set it to choose the status. Left unset, the returned value decides: null becomes 204, everything else 200.

ctx.status = 201

ctx.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')

Builds a redirect response. 302 unless you say otherwise.

app.get('/old', (ctx) => ctx.redirect('/new', 301))

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.

The headers staged so far, or undefined if none have been set. Mostly of interest to middleware inspecting what a handler did.

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.

app.service('db') reaches the same configured value with no request in sight — for a migration script, a seed, or oven worker. See App.