Skip to content

HTTP framework for Bun

What you write
is what runs.

Lifecycle hooks in fixed slots, controllers that take their dependencies as arguments, and types inferred from the schema to the handler. No decorators, no container, no dependencies.

Get startedbun add @tetsujs/core
app.ts
import { controller, createApp, httpError, route } from "@tetsujs/core";
import { z } from "zod";
const users = controller("Users", ({
repo: UserRepository
repo
}: {
repo: UserRepository
repo
: UserRepository }) => ({
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",
schema: { params: z.object({ id: z.coerce.number() }) },
handler: (ctx: {
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}) => User

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 req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx
) => {
const
const user: User | undefined
user
=
repo: UserRepository
repo
.find(
ctx: {
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx
.
params: {
id: number;
}
params
.id);
id: number
if (!
const user: User | undefined
user
) throw httpError(404, "USER_NOT_FOUND");
return
const user: User
user
;
},
}),
}));
Bun.serve({ ...createApp({ routes: users({
repo: UserRepository
repo
}) }) });

Caught before it runs

The compiler knows what the framework knows.

const latestOrder = hook.beforeHandle(
(
ctx: Requires<{
user: User;
}>
ctx
: Requires<{
user: User
user
: User }>) => ({
order: Order
order
:
const orders: OrderService
orders
.listFor(
ctx: Requires<{
user: User;
}>
ctx
.
user: User
user
.
id: number
id
)[0] }),
);
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: "/orders/latest"

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
: "/orders/latest",
hooks: { beforeHandle: [latestOrder] },
Error ts(2322) ― Hook requires context 'user' which is not provided by path, schema or preceding hooks
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: {};
order: Order;
}) => unknown

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: {};
order: Order;
}
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: {};
order: Order;
}
ctx
.
order: Order
order
,
});
const UserDto = z.object({ id: z.number(), name: z.string() });
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: "/users"

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",
schema: { response: { 200: UserDto } },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => User

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 & DeclaredOutgoing<200>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => {
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing & DeclaredOutgoing<200>

Response parameters for the serialized handler result.

out
.
status: 200 | undefined
status
= 201;
Error ts(2322) ― Type '201' is not assignable to type '200'.
return
const repo: UserRepository
repo
.create({
name: string
name
: "Ada",
email: string
email
: "ada@example.com" });
},
});
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/*/raw",
Error ts(2322) ― A '*' must be the entire final segment: Bun's router matches '/files/*', but a wildcard anywhere else never matches any request
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("…"),
});
// queries/customer-orders.sql
// select o.id, o.status, o.total, s.carrier, s.shipped_at
// from orders o
// left join shipments s on s.order_id = o.id
// where o.customer_id = :customerId
import { customerOrders } from "./queries.generated";
for (const
const order: CustomerOrdersRow
order
of await customerOrders(
const db: Kysely<DB>
db
, {
customerId: bigint
customerId
})) {
console.log(
const order: CustomerOrdersRow
order
.
shipped_at: Date | null
shipped_at
.toISOString());
Error ts(18047) ― 'order.shipped_at' is possibly 'null'.
}

How it reads

Plain objects, wired by hand.

The body is read and checked before the handler runs. A failure is a 422 with the issues; ctx.body is what the schema returned.

const UserDto = z.object({ id: z.number(), name: z.string() });
export const createUser = 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: "/users"

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",
schema: {
body: z.object({ name: z.string().min(1), email: z.email() }),
response: { 201: UserDto },
},
handler: (ctx: {
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}) => User

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 & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}
ctx
) => {
ctx: {
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}
ctx
.
out: Outgoing & DeclaredOutgoing<201>

Response parameters for the serialized handler result.

out
.
status: 201 | undefined
status
= 201;
return
const repo: UserRepository
repo
.create(
ctx: {
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}
ctx
.
body: {
name: string;
email: string;
}
body
);
},
});

Performance

A fraction of a microsecond over Bun itself.

Processor time per request, lower is better. The same four routes in every framework, each server and load generator in its own process. Not the fastest on every route, but close to Bun on all of them.

GET /ping
  • Bun, no framework4.78 µs
  • Tetsu4.95 µs +0.17
  • Elysia 1.44.89 µs +0.11
  • Hono 4.135.16 µs +0.38
GET /users/:id + 2 hooks
  • Bun, no framework4.81 µs
  • Tetsu5.09 µs +0.28
  • Elysia 1.45.13 µs +0.32
  • Hono 4.135.87 µs +1.06
POST /items, validated
  • Bun, no framework5.85 µs
  • Tetsu6.60 µs +0.75
  • Elysia 1.46.31 µs +0.46
  • Hono 4.136.88 µs +1.03
404
  • Bun, no framework4.82 µs
  • Tetsu5.13 µs +0.31
  • Elysia 1.44.91 µs +0.09
  • Hono 4.135.41 µs +0.59

Bun 1.4.2, Apple M5 Pro, 2026-09-27, medians of three interleaved rounds. Method and caveats

Principles

Iron, not magic.

  1. Controllers without decorators

    A named function from its dependencies to its routes, called once where the application is wired.

  2. Flat, and no magic

    No registry, no file scanning, no reflection, no container. Any point of the application reads top to bottom.

  3. Types do not lie

    A field is on ctx exactly when it exists: the body after validation, the user after the hook that found them.

  4. Explicit over DRY

    A route lists its hooks by slot, in the order they run. Similar routes repeat them instead of inheriting them.

  5. The platform, not a wrapper

    Bun's router, Headers, CookieMap, the real server. No dependencies in the core.

  6. Why it is built this way →

Packages

Official, versioned together.

Hooks, controllers and functions, used like your own code. Nothing is registered.

Start with one route.

Bun 1.4 and TypeScript 5.7 or later. Nothing else.

Get startedbun add @tetsujs/core