Skip to Content

Handlers

Everything a handler can read from the request and write to the response.

A handler is the handle function of a route. It receives one object and returns the response.

route.get('/users/:id', {
  handle: ({ params, query, body, headers, ctx, request }) => {
    return { id: params.id };
  },
});
You want toUse
Read a path parameterparams.id
Read the query stringquery.page
Read the JSON or form bodybody, with a body schema
Read a headerheaders.get('authorization')
Read a cookiectx.cookies.get('session')
Send JSONreturn { ... }
Set the statusstatus: 201 on the route
Set a headerctx.setHeader('cache-control', 'no-store')
Set a cookiectx.cookies.set('session', value)
Redirectreturn redirect('/login')
Failthrow new NotFoundException()

Reading the request

Path parameters

route.get('/orgs/:org/repos/:repo', {
  handle: ({ params }) => `${params.org}/${params.repo}`,   // both are strings
});

Without a schema they are strings. With a params schema they are whatever it produces.

route.get('/users/:id', {
  params: z.object({ id: z.coerce.number().int().positive() }),
  handle: ({ params }) => users.find(params.id),             // a number
});

Query string

// GET /search?q=suku&tag=a&tag=b
route.get('/search', {
  handle: ({ query }) => query,     // { q: 'suku', tag: ['a', 'b'] }
});

A key that appears once is a string and a repeated key is an array. A query schema gives you types, defaults and one consistent shape.

route.get('/search', {
  query: z.object({
    q: z.string().default(''),
    page: z.coerce.number().int().min(1).default(1),
    tag: z.union([z.string(), z.array(z.string())]).transform((v) => (Array.isArray(v) ? v : [v])).default([]),
  }),
  handle: ({ query }) => search(query.q, query.page, query.tag),
});

Headers

route.get('/me', {
  handle: ({ headers }) => {
    const language = headers.get('accept-language') ?? 'en';
    return { language };
  },
});

headers is the standard Headers object. Names are case-insensitive.

Cookies

route.get('/preferences', {
  handle: ({ ctx }) => ({
    theme: ctx.cookies.get('theme') ?? 'light',
    returning: ctx.cookies.has('visited'),
  }),
});
MethodReturns
ctx.cookies.get(name)The value, or undefined.
ctx.cookies.has(name)Whether the client sent it.
ctx.cookies.all()Every cookie as an object.

Values are URL-decoded. A cookie is input from the client like any other: never trust one you have not signed or sealed.

Body

Give the route a body schema and the handler receives the validated result.

route.post('/users', {
  body: z.object({ name: z.string().min(2), email: z.email() }),
  handle: ({ body }) => users.create(body),
});

Without a schema body is undefined and nothing is read. Call ctx.body() to parse it yourself; the format is chosen from the Content-Type.

route.post('/contact', {
  handle: async ({ ctx }) => {
    const form = await ctx.body<{ email: string; message: string }>();   // JSON or a form post
    return send(form);
  },
});

File uploads

A multipart form arrives as a standard FormData, and each file as a File.

route.post('/avatar', {
  handle: async ({ ctx }) => {
    const form = await ctx.body<FormData>();
    const file = form.get('file');
    if (!(file instanceof File)) throw new BadRequestException('A file is required');
    if (file.size > 2_000_000) throw new PayloadTooLargeException();
    await storage.put(`avatars/${crypto.randomUUID()}`, await file.arrayBuffer(), file.type);
    return { name: file.name, size: file.size };
  },
});

Raise the app's body limit for upload routes with bodyLimit in Suku.create. The default is 1 MiB.

Raw body and streams

For a webhook signature or a large upload, read the request directly.

route.post('/webhooks/payments', {
  handle: async ({ request }) => {
    const raw = await request.text();          // or arrayBuffer(), or request.body as a stream
    return verifyAndHandle(raw);
  },
});

The body can be read once. Use request or ctx.body(), not both.

Everything else

route.get('/debug', {
  handle: ({ ctx, request }) => ({
    method: ctx.method,
    path: ctx.url.pathname,
    host: ctx.url.host,
    ip: ctx.remoteAddress,          // behind a proxy this is the proxy
    route: ctx.route?.name,
    user: ctx.get('user'),          // set by a middleware
    signal: request.signal.aborted, // true if the client went away
  }),
});

See the request context for the full list.

Writing the response

Returning data

ReturnedSent as
An object or arrayJSON, 200
A stringtext/plain, 200
undefined or null204 No Content
Uint8Array or ArrayBufferapplication/octet-stream
A ReadableStream or BlobA streamed body
A ResponseExactly as it is

POST routes default to 201. Handlers may be async.

Status

Set a fixed status on the route.

route.post('/jobs', {
  status: 202,
  handle: ({ body }) => queue.add(body),
});

For a status decided at runtime, return a Response.

route.put('/items/:id', {
  handle: async ({ params, ctx }) => {
    const { item, created } = await items.upsert(params.id, await ctx.body());
    return Response.json(item, { status: created ? 201 : 200 });
  },
});

For an error status, throw. See errors and status codes for the full list of exceptions and how to write your own.

throw new HttpException(402, 'Subscription required');

Headers

ctx.setHeader adds a header to whatever you return, so plain data stays plain.

route.get('/users', {
  handle: async ({ ctx }) => {
    const { items, total } = await users.page();
    ctx.setHeader('x-total-count', String(total)).setHeader('cache-control', 'private, max-age=30');
    return items;
  },
});

If you return a Response that already has the same header, the response's value wins.

Cookies

route.post('/login', {
  body: Login,
  status: 200,
  handle: async ({ body, ctx }) => {
    const session = await sessions.create(await authenticate(body));
    ctx.cookies.set('session', session.id, { maxAge: 60 * 60 * 24 * 7 });
    return { ok: true };
  },
});

route.post('/logout', {
  handle: ({ ctx }) => {
    ctx.cookies.delete('session');
  },
});

Cookies set this way are added to the final response whatever it is: plain data, a Response you built, a redirect, or an error.

OptionDefaultMeaning
maxAgenoneSeconds to live. Without it the cookie lasts until the browser closes.
expiresnoneAn absolute Date. Prefer maxAge.
path/
domainnoneSet it to share the cookie with subdomains.
httpOnlytruePage scripts cannot read the cookie.
securetrueSent over HTTPS only.
sameSiteLaxStrict, Lax or None. None requires secure.
partitionedfalseFor embedded third-party contexts.

The defaults are the safe ones. Turn httpOnly off only for a value your own scripts must read, such as a UI preference.

ctx.cookies.set('theme', 'dark', { httpOnly: false, maxAge: 31_536_000 });

To delete a cookie, path and domain must match the ones it was set with.

Local development

Browsers accept Secure cookies on http://localhost, except Safari. If cookies vanish there, pass secure: false in development only.

For cookies the client must not read or alter, seal the value first.

ctx.cookies.set('session', await sealer.seal({ userId: user.id }, { ttl: '7d' }), { maxAge: 604_800 });

const data = await sealer.unseal<{ userId: string }>(ctx.cookies.get('session') ?? '');

Redirects

import { redirect } from '@sukujs/core';

route.get('/old-page', { handle: () => redirect('/new-page', 301) });

route.post('/login', {
  handle: async ({ ctx }) => {
    ctx.cookies.set('session', await createSession(ctx));
    return redirect('/dashboard');      // 302 by default
  },
});

Use 303 after a form POST, so the browser follows with a GET.

Files and downloads

route.get('/reports/:id/export', {
  handle: async ({ params }) =>
    new Response(await reports.csv(params.id), {
      headers: {
        'content-type': 'text/csv; charset=utf-8',
        'content-disposition': `attachment; filename="report-${params.id}.csv"`,
      },
    }),
});

Pass a stream as the body to send a large file without holding it in memory.

Streaming

Return a ReadableStream to send the body as it is produced.

route.get('/export', {
  handle: () => {
    const encoder = new TextEncoder();
    return new ReadableStream<Uint8Array>({
      async start(controller) {
        for await (const row of db.rows()) controller.enqueue(encoder.encode(`${JSON.stringify(row)}\n`));
        controller.close();
      },
    });
  },
});

Server-sent events

route.get('/events', {
  handle: ({ request }) => {
    const encoder = new TextEncoder();
    const stream = new ReadableStream<Uint8Array>({
      start(controller) {
        const send = (data: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
        const unsubscribe = events.subscribe(send);
        request.signal.addEventListener('abort', () => {
          unsubscribe();
          controller.close();
        });
      },
    });
    return new Response(stream, {
      headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
    });
  },
});

request.signal fires when the client disconnects. Use it to stop work nobody is waiting for.

Errors

Throw to end the request with an error response.

route.get('/users/:id', {
  handle: async ({ params }) => {
    const user = await users.find(params.id);
    if (!user) throw new NotFoundException(`User ${params.id} not found`);
    return user;
  },
});

See errors and status codes for every built-in exception, custom exception classes, and mapping errors to responses.

Using services

In a @Routes class, services come through the constructor and handlers use this. In a plain route, list them under services and they arrive beside the request parts.

route.get('/invoices/due', {
  services: { invoices: Invoices, clock: Clock },
  handle: ({ invoices, clock }) => invoices.dueBefore(clock.now()),
});

From a middleware

Everything on ctx is available in a middleware too, so a middleware can read a cookie, set a header, or share data with the handler.

const requestId = middleware((ctx, next) => {
  const id = ctx.headers.get('x-request-id') ?? crypto.randomUUID();
  ctx.set('requestId', id);
  ctx.setHeader('x-request-id', id);
  return next();
});

On this page