Custom Handlers

When a template isn't enough, give the page a handler: sandboxed JavaScript with full control of the request/response cycle. Runs in an isolated Dynamic Worker.

Basics

The handler code is the body of an async function receiving a ctx object. It must return a Response. Handlers respond to GET, POST, PUT, PATCH, and DELETE — branch on ctx.method. Pages without a handler only render templates on GET/POST.

ctx object

PropertyDescription
ctx.methodHTTP method
ctx.urlFull request URL
ctx.paramsURL path params (/posts/:id → ctx.params.id)
ctx.queryQuery string params
ctx.bodyParsed request body (after validation)
ctx.headersRequest headers
ctx.pageThe matched page record

ctx methods

MethodDescription
ctx.db.query(sql, params?)SQL query → { rows, meta } (data statements only)
ctx.db.batch(statements)Multiple queries in one round-trip
ctx.render(template, data)Render a template string with the template engine
ctx.sendEmail(opts)Send transactional email
ctx.trigger(event, data)Fire a custom webhook event
ctx.html(json/status) ctx.json ctx.textTyped responses
ctx.redirect(url, status?)Redirect response (default 302)
ctx.fetch(input, init?)Outbound HTTP requests

Examples

JSON API endpoint

Page path /api/posts/:id:

const post = await ctx.db.query(
  "SELECT * FROM __posts WHERE id = ?",
  [ctx.params.id]
);
if (post.rows.length === 0) return ctx.json({ error: "Not found" }, 404);
return ctx.json(post.rows[0]);

Send email on form submission

await ctx.sendEmail({
  to: "admin@example.com",
  subject: "New contact form submission",
  html: `<p>Name: ${ctx.body.name}</p><p>Message: ${ctx.body.message}</p>`
});
return ctx.redirect("/thank-you");

Render a template inside a handler

const product = await ctx.db.query(
  "SELECT * FROM __products WHERE id = ?", [ctx.params.id]);
const html = ctx.render(
  `<h1>{{ product.name }}</h1>`,
  { product: product.rows[0] }
);
return ctx.html(html);

Page validation

Pages can define validation rules that run before rendering and handlers. The validation field takes params, query, and body arrays using the same field definition format:

{
  "params": [{ "name": "id", "type": "uuid", "required": true }],
  "query":  [{ "name": "tab", "type": "string", "enum": ["details", "reviews"] }],
  "body":   [{ "name": "rating", "type": "number", "min": 1, "max": 5 }]
}

Failed validation returns 404 — intentionally, to avoid revealing that a route exists.

How it works

  1. Validation rules run first — the handler only fires if they pass.
  2. The code is hashed (SHA-256); the hash keys the Worker Loader isolate cache. Editing the code spins up a fresh isolate.
  3. DB, email, and render calls are proxied to the parent worker via RPC — the handler sandbox has no direct binding access.