Skip to content

Routes and handlers

A route is one method on one path, and the handler that answers it.

route() takes a configuration object and returns it as data. Nothing is registered when it runs; createApp() reads the routes later.

import { controller, route } from "@tetsujs/core";
export const notesController = controller("Notes", () => ({
get: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/notes/:id"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/notes/:id",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}) => {
id: string;
title: string;
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
) => ({
id: string
id
:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
.
params: {
id: string;
}
params
.
id: string
id
,
title: string
title
: "First note" }),
}),
}));

Never annotate ctx. Its type is inferred from the rest of the configuration: the path parameters, the validated parts, and what hooks add. The fields are:

Field Required
method yes GET, POST, PUT, PATCH or DELETE
path yes the path, with :param segments
handler yes the function that answers the request
schema no schemas for params, query, headers, cookies, body and response — see Validation
hooks no hooks keyed by slot — see Lifecycle hooks
bodyType no "json", "form", "text" or "stream" — see Request bodies
maxBodySize no this route’s body limit in bytes, in place of the application’s
rawBody no keep the body’s bytes next to the parsed body, for signatures
docs no summary, description, tags, deprecated, hidden, operationId — read by @tetsujs/openapi only

The route reference lists them with their types.

The value a handler returns becomes the response:

  • a value is sent as JSON with status 200;
  • undefined, or no return at all, is 204 with no body;
  • a Response is sent as it is.
import { controller, route } from "@tetsujs/core";
import { z } from "zod";
export const notesController = controller("Notes", () => ({
create: route({
method: "POST"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "POST",
path: "/notes"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/notes",
schema: { body: z.object({ title: z.string().min(1) }) },
handler: (ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
};
}) => Note

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
};
}
ctx
) => {
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
};
}
ctx
.
out: Outgoing

Response parameters for the serialized handler result.

out
.
status: number | undefined
status
= 201;
return
const notes: {
add(title: string): Note;
remove(id: number): void;
}
notes
.add(
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
};
}
ctx
.
body: {
title: string;
}
body
.
title: string
title
);
},
}),
remove: route({
method: "DELETE"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "DELETE",
path: "/notes/:id"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/notes/:id",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}) => void

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
) => {
const notes: {
add(title: string): Note;
remove(id: number): void;
}
notes
.remove(Number(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
.
params: {
id: string;
}
params
.
id: string
id
));
},
}),
export: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/notes/export"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/notes/export",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Response

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: () => new Response("id,title\n", {
headers?: HeadersInit | undefined
headers
: { "content-type": "text/csv" } }),
}),
}));

Set the status and headers of a JSON result on ctx.out; a Response carries its own. A handler may be async. Responses covers response schemas, redirects and streams, and Errors covers what a thrown error becomes.

Each :name segment becomes a field of ctx.params, typed from the path literal:

route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/users/:userId/notes/:noteId"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/users/:userId/notes/:noteId",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
userId: string;
noteId: string;
};
}) => {
userId: string;
noteId: string;
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
userId: string;
noteId: string;
};
}
ctx
) =>
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
userId: string;
noteId: string;
};
}
ctx
.params,
params: {
userId: string;
noteId: string;
}
});

Parameters are strings. To get a number, a UUID or an enum, declare a params schema; see Validation. A path without parameters has an empty ctx.params.

A path starts with / and has no empty segments and no trailing slash. A parameter takes a whole segment. A * is allowed only as the whole last segment, where it matches the rest of the path:

import { route } from "@tetsujs/core";
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/users/:userId/notes/:noteId"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/users/:userId/notes/:noteId",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
userId: string;
noteId: string;
};
}) => undefined

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: () => undefined });
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/files/*"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/files/*",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => undefined

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: () => undefined });

These are the rules of Bun’s router, and a path that breaks them does not compile. That includes two spellings that look right but are not: {id} and the optional parameter :id?, which Bun’s router does not support:

route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET", path: "/users/{id}",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => undefined

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: () => undefined });
Error ts(2322) ― A parameter is ':id', not '{id}': Bun's router matches braces as the characters they are, so '/users/{id}' answers only a request for that literal path
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET", path: "/users/:id?",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
"id?": string;
};
}) => undefined

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: () => undefined });
Error ts(2322) ― Bun's router has no optional parameters: ':id?' matches only a present value and names the parameter 'id?'

A path the compiler cannot see, such as one built from a variable, is checked with the same rules when route() runs.

createApp() also refuses two mistakes at startup: the same method and path declared twice, and two paths that differ only in parameter names, such as /users/:id and /users/:userId. Bun’s router treats those as one pattern.

A * captures nothing: ctx.params has no field for it. Read the rest of the path from ctx.req.url.

A route declares GET, POST, PUT, PATCH or DELETE. Every path answers HEAD and OPTIONS on its own: HEAD runs the path’s GET route and sends the headers without the body, and OPTIONS answers 204 with an Allow header. Any other method on a known path gets 405 with the same Allow.

ctx.route describes the route that matched:

import { controller, route } from "@tetsujs/core";
export const usersController = controller("Users", () => ({
get: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/users/:id"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/users/:id",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}) => {
id: string;
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
) => {
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
.
route: RouteInfo

The route this request matched, or nothing when none did.

Optional because a 404, a 405 and a preflight run the pipeline too, and there is no route behind them — the same reason req.cookies is optional. In a route's own context the field is not optional: a hook mounted on a route, and its handler, always have one.

It is the field that makes an observer able to name the endpoint rather than the URL: ctx.route.path is /users/:id, where new URL(ctx.req.url).pathname is /users/42. The difference is cosmetic in a log line and structural in a metric, where a label built from the second one grows a new series per identifier.

route
.
path: string

The path as the route declared it, with group prefixes joined and :params left as they are — /api/users/:id, never /api/users/42.

path
; // "/users/:id"
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
.
route: RouteInfo

The route this request matched, or nothing when none did.

Optional because a 404, a 405 and a preflight run the pipeline too, and there is no route behind them — the same reason req.cookies is optional. In a route's own context the field is not optional: a hook mounted on a route, and its handler, always have one.

It is the field that makes an observer able to name the endpoint rather than the URL: ctx.route.path is /users/:id, where new URL(ctx.req.url).pathname is /users/42. The difference is cosmetic in a log line and structural in a metric, where a label built from the second one grows a new series per identifier.

route
.
method: string

The method this route answers.

method
; // "GET"
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
.
route: RouteInfo

The route this request matched, or nothing when none did.

Optional because a 404, a 405 and a preflight run the pipeline too, and there is no route behind them — the same reason req.cookies is optional. In a route's own context the field is not optional: a hook mounted on a route, and its handler, always have one.

It is the field that makes an observer able to name the endpoint rather than the URL: ctx.route.path is /users/:id, where new URL(ctx.req.url).pathname is /users/42. The difference is cosmetic in a log line and structural in a metric, where a label built from the second one grows a new series per identifier.

route
.
controller?: string | undefined

The name of the controller the route was collected from — the one controller() gave it, or its class's. Absent for an object literal and a route mounted standalone, which have nothing to be named after.

controller
; // "Users"
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
.
route: RouteInfo

The route this request matched, or nothing when none did.

Optional because a 404, a 405 and a preflight run the pipeline too, and there is no route behind them — the same reason req.cookies is optional. In a route's own context the field is not optional: a hook mounted on a route, and its handler, always have one.

It is the field that makes an observer able to name the endpoint rather than the URL: ctx.route.path is /users/:id, where new URL(ctx.req.url).pathname is /users/42. The difference is cosmetic in a log line and structural in a metric, where a label built from the second one grows a new series per identifier.

route
.
name?: string | undefined

The field name the route was declared under, when it had one.

Absent for a RouteDef mounted on its own, which has no field to be named by. It is the same name @tetsujs/openapi builds an operationId from, so a metric labelled with it and an operation in the document agree on what the endpoint is called.

name
; // "get"
return {
id: string
id
:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
.
params: {
id: string;
}
params
.
id: string
id
};
},
}),
}));

path is the template with group prefixes joined, never the URL. Use it to label logs and metrics: a label built from the URL creates a new series for every id. controller and name are absent for a route mounted on its own, outside a controller. In hooks that also run for a 404, a 405 or a preflight, ctx.route is optional, because no route matched.

Routing is done by Bun’s native router. createApp() turns each declared path into one entry of the routes object Bun.serve takes, and that entry picks the route by method. There is no second matcher, so the precedence between overlapping paths is Bun’s.

app.fetch only handles requests that matched no path: it answers 404, or runs the fallback option of createApp. Calling app.fetch directly does not route, so integration tests go through a real server; see Testing.