Skip to main content

Request API

The ctx object your handlers receive exposes request data through a small, consistent set of properties. This page shows how to use each one.

ctx is a BurgerContext. It is not a Request subclass: the original request is available as ctx.request, and the standard request surface is delegated.

The examples on this page assume import type { BurgerContext } from "burger-api";.

ctx.request​

The raw Request. Use it when you need the full request object:

export async function GET(ctx: BurgerContext) {
const url = new URL(ctx.request.url);
return Response.json({ path: url.pathname });
}

ctx.params​

Dynamic path parameters:

api/users/[id]/route.ts
export async function GET(ctx: BurgerContext) {
const { id } = ctx.params;
return Response.json({ id });
}

When to use: any time the route has a [param] segment. Wildcard segments are available as ctx.wildcardParams.

ctx.query​

The parsed query string, available lazily:

export async function GET(ctx: BurgerContext) {
const { limit = "10", tag } = ctx.query;
return Response.json({ limit, tag });
}

Why: ctx.query parses only when you read it, so requests that don't use the query pay nothing for parsing. See Query Parsing.

ctx.cookies​

Parsed cookies, also lazy. The record maps cookie names to values.

ctx.body​

The raw request body stream (ReadableStream | null), delegated from ctx.request.

ctx.json() and ctx.text()​

Read the request body as JSON or text. The body is read once and cached on ctx, so ctx.json(), ctx.text() and ctx.arrayBuffer() can be called after validation read the stream:

export async function POST(ctx: BurgerContext) {
const body = await ctx.json();
return Response.json({ received: body });
}

When the route declares a body schema, read the validated body from ctx.validated.body instead. It is typed from the schema and non-optional after validation. Calling ctx.json() still works (the parsed body is cached), but the validated value is the one to use. A declared body schema also means non-JSON requests are rejected with 415 Unsupported Media Type before the handler runs.

ctx.headers, ctx.method, ctx.url​

The request headers, HTTP method, and full URL. All delegate to the underlying request and never allocate anything extra:

export async function GET(ctx: BurgerContext) {
const token = ctx.headers.get("authorization");
return Response.json({ method: ctx.method });
}

ctx.route​

The matched route's identity:

export async function GET(ctx: BurgerContext) {
// ctx.route?.pattern === "/api/users/:id" (includes apiPrefix)
return Response.json({ pattern: ctx.route?.pattern });
}

The route is always set in a matched handler, but the type is RouteMeta | undefined, so use optional chaining. See Route Metadata.

ctx.validated​

Data that passed your schemas, typed from schema.ts:

api/products/schema.ts
import { z } from "zod";

export const GET = { query: z.object({ limit: z.coerce.number().optional() }) };
api/products/route.ts
import { defineRoute } from "burger-api";
import { GET as GetSchema } from "./schema";

export const GET = defineRoute(GetSchema, (ctx) => {
const { limit } = ctx.validated.query; // typed + validated
return Response.json({ limit });
});

Why: validated data is typed from your schema, so handlers know exactly what they received without manual casts. See Validation.

ctx.services​

Application services registered in src/providers.ts, typed via module augmentation. The services object is app-scoped, shared by every request, and frozen. Read from it, never assign to it; per-request data belongs in a transform hook instead. See Configuration.

ctx.ip​

The client socket address, resolved lazily on first read. It is never read from forwarded headers such as X-Forwarded-For: those are client-controlled. On Bun, serve() reads it from the server socket; @burger-api/node-server sets it from the Node socket; on plain toFetchHandler runtimes it is undefined. The Rate Limiter hook uses it as the default client key.

ctx.set​

Response mutations, covered in Response Mutation.

Types for this feature​

The type you use: BurgerContext, or BurgerContext<typeof GET> when the route has a schema.

✅ Correct: type the handler parameter:

import type { BurgerContext } from "burger-api";
export async function GET(ctx: BurgerContext) {
const { limit = "10" } = ctx.query;
return Response.json({ limit });
}

✅ Correct: give the JSON body a type with ctx.json<T>():

export async function POST(ctx: BurgerContext) {
const body = await ctx.json<{ name: string }>();
return Response.json({ name: body.name });
}

❌ Wrong: untyped handler parameters:

export async function GET(ctx) {
// ❌ Parameter 'ctx' implicitly has an 'any' type
}

Unannoted BurgerContext keeps every request field (query, params, cookies) untyped. For typed request data, add a schema and wrap the handler with defineRoute(schema, handler) (or use BurgerContext<typeof GET> directly). See Validation and the TypeScript overview.

Check your code: bun run typecheck.