Skip to main content
Every Anvil handler and middleware receives a single Context object. It is an immutable view of the incoming request plus a set of response-building helpers — Anvil’s replacement for Express’s mutation-heavy (req, res) pair. Import the type from anvil:
import type { Context } from 'anvil';

export default async function handler(ctx: Context) {
  // ...
}

Request properties

ctx.req
Request
The underlying Web-standard Request object. Use this when you need access to APIs not exposed directly on Context, such as ctx.req.arrayBuffer() or ctx.req.signal.
export default async function handler(ctx: Context) {
  const rawBody = await ctx.req.arrayBuffer();
  // ...
}
ctx.method
string
The HTTP method in uppercase, e.g. "GET", "POST", "DELETE". Delegates to ctx.req.method.
export default function handler(ctx: Context) {
  console.log(ctx.method); // "GET"
}
ctx.path
string
The URL pathname without the query string, e.g. "/users/42". Equivalent to new URL(ctx.req.url).pathname.
export default function handler(ctx: Context) {
  console.log(ctx.path); // "/users/42"
}
ctx.headers
Headers
The request headers as a Web-standard Headers object. Access individual headers with .get().
export default function handler(ctx: Context) {
  const contentType = ctx.headers.get('content-type');
  const auth = ctx.headers.get('authorization');
}
ctx.params
Record<string, string>
Named path parameters captured from the route pattern. The keys correspond to the dynamic folder names in your server/routes/ tree (without the brackets).
// Route: server/routes/users/[id]/get.ts → GET /users/:id
export default function handler(ctx: Context) {
  console.log(ctx.params.id); // "42" for GET /users/42
}
For catch-all routes ([...param]), ctx.params.param contains the full remaining path joined with /:
// Route: server/routes/files/[...path]/get.ts → GET /files/*
export default function handler(ctx: Context) {
  console.log(ctx.params.path); // "images/2024/photo.png" for GET /files/images/2024/photo.png
}
ctx.query
Record<string, string | string[]>
The URL query string parsed into a plain object. Repeated keys are collected into an array; single-occurrence keys are plain strings.
// GET /search?q=anvil&tag=node&tag=typescript
export default function handler(ctx: Context) {
  console.log(ctx.query.q);    // "anvil"
  console.log(ctx.query.tag);  // ["node", "typescript"]
}
The result is computed lazily on first access and cached for the lifetime of the request.
ctx.state
Record<string, unknown>
A mutable scratch space for passing data from middleware to the handler (or to a later middleware). Anvil does not touch this object — it is entirely yours to use.
// In middleware:
ctx.state.user = await verifyToken(token);

// In the handler:
const user = ctx.state.user as User;
See Scoped Middleware for a full auth-guard example.

Body parsing

ctx.body<T>()
() => Promise<T>
Parse the request body based on the Content-Type header:
  • application/json → parsed with JSON.parse(), returns the resulting value
  • application/x-www-form-urlencoded or multipart/form-data → returns a FormData object
  • Anything else → returns the body as a plain string
The result is cached after the first call — calling ctx.body() from both a middleware and the handler is safe and reads the stream only once.
export default async function handler(ctx: Context) {
  const body = await ctx.body<{ name: string; email: string }>();
  // body is typed as { name: string; email: string }
  return { received: body };
}
If the body cannot be parsed (e.g., malformed JSON), Anvil throws an HttpError(400, 'Malformed request body') automatically.

Response helpers

All response helpers accept an optional init parameter of type ResponseInit (the same second argument as the Web Response constructor) for setting status codes and custom headers.
ctx.json(data, init?)
(data: unknown, init?: ResponseInit) => Response
Serialize data to JSON and return a Response with Content-Type: application/json. Delegates to Response.json().
return ctx.json({ id: '1', name: 'Alice' });
// With a custom status:
return ctx.json({ created: true }, { status: 201 });
ctx.text(data, init?)
(data: string, init?: ResponseInit) => Response
Return a Response with Content-Type: text/plain; charset=utf-8.
return ctx.text('Hello, world!');
return ctx.text('Not found', { status: 404 });
ctx.html(data, init?)
(data: string, init?: ResponseInit) => Response
Return a Response with Content-Type: text/html; charset=utf-8.
return ctx.html('<h1>Hello</h1>');
ctx.redirect(location, status?)
(location: string, status?: number) => Response
Return a redirect response. status defaults to 302. Use 301 for a permanent redirect.
return ctx.redirect('/users/profile');
return ctx.redirect('https://example.com', 301);
ctx.stream(body, init?)
(body: ReadableStream, init?: ResponseInit) => Response
Return a streaming response. Pass a ReadableStream as the body — useful for server-sent events, large file transfers, and AI model output.
export default function handler(ctx: Context) {
  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue(new TextEncoder().encode('data: hello\n\n'));
      controller.close();
    },
  });
  return ctx.stream(stream, {
    headers: { 'content-type': 'text/event-stream' },
  });
}

Throwing HTTP errors

Use HttpError (imported from anvil) to throw an HTTP error from a handler or middleware. Anvil’s kernel catches it and converts it to a structured JSON error response automatically.
import { HttpError, type Context } from 'anvil';

export default async function handler(ctx: Context) {
  const token = ctx.headers.get('authorization');
  if (!token) throw new HttpError(401, 'Missing Authorization header');

  const body = await ctx.body<{ name?: string }>();
  if (!body || typeof body !== 'object' || !('name' in body)) {
    throw new HttpError(400, 'Expected a JSON body with a "name" field');
  }

  return { ok: true };
}
The JSON response looks like:
{
  "error": "Missing Authorization header",
  "status": 401
}
The full constructor signature is:
new HttpError(status: number, message?: string, opts?: {
  details?: unknown;   // extra data included in the response body (4xx only by default)
  expose?: boolean;    // override the default expose logic (true = send message/details to client)
  cause?: unknown;     // the underlying error, forwarded as Error.cause for stack traces
})
status
number
required
The HTTP status code. 4xx errors expose their message to the client by default; 5xx errors do not (the client sees the generic status message instead).
message
string
The error message. For 4xx errors, this is sent to the client. For 5xx errors, it is hidden in production and only visible in dev mode (NODE_ENV !== 'production').
opts.details
unknown
Optional additional data included in the details field of the JSON response. Only sent to the client when expose is true (the default for 4xx errors).
opts.expose
boolean
Override the default expose behaviour. Set to true to force the message and details onto a 5xx response in development, or false to hide them on a 4xx.
opts.cause
unknown
The underlying error that caused this HttpError. Forwarded as the standard Error.cause property — visible in stack traces but never sent to the client.

Full handler example

Here is a handler that demonstrates multiple Context features together:
server/routes/users/[id]/get.ts
import { HttpError, type Context } from 'anvil';
import { z } from 'zod';

export const meta = { mcp: { expose: true, description: 'Fetch a user by ID' } };
export const paramsSchema = z.object({ id: z.string() });

const USERS = [
  { id: '1', name: 'Alice', role: 'admin' },
  { id: '2', name: 'Bob', role: 'user' },
];

export default function handler(ctx: Context) {
  // Read a dynamic route param
  const { id } = ctx.params;

  // Read an optional query parameter
  const format = ctx.query.format; // e.g. ?format=verbose

  // Read a request header
  const requestId = ctx.headers.get('x-request-id') ?? 'unknown';

  // Read authenticated user from middleware-set state
  const viewer = ctx.state.user as { role: string } | undefined;

  const user = USERS.find((u) => u.id === id);
  if (!user) throw new HttpError(404, `No user with id "${id}"`);

  // Restrict sensitive fields based on the viewer's role
  if (viewer?.role !== 'admin' && user.role === 'admin') {
    throw new HttpError(403, 'Forbidden');
  }

  return ctx.json(
    format === 'verbose' ? { user, requestId } : user,
  );
}