Skip to content

Responses

A handler answers by returning a value, and the framework turns that value into the response.

The handler returns The response
undefined, or nothing 204, no body
a Response sent as it is
any other value JSON, with 200

ctx.out.status replaces the 200 or 204 of a serialized value. It has no effect on a Response, which states its own status:

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: "/orders"

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",
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: number;
}

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

Response parameters for the serialized handler result.

out
.
status: number | undefined
status
= 201;
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.set("location", `/orders/${
const order: {
id: number;
}
order
.
id: number
id
}`);
return
const order: {
id: number;
}
order
;
},
});

A ReadableStream or a generator returned bare is a compile error, and a 500 at runtime: as JSON it would be {}. A stream goes out inside a Response that states its content type. See Streaming.

ctx.out is what the response carries besides its body. ctx.headers and ctx.cookies are what arrived; ctx.out.headers and ctx.out.cookies are what leaves.

Field
status the status of a serialized result
headers a standard Headers
cookies set(name, value, attributes) and delete(name, attributes), see Cookies

Every hook and the handler share the same ctx.out. headers is never replaced, only changed with set() and append(), so two hooks that write headers do not overwrite each other’s.

ctx.out.headers is applied to every response that leaves: a serialized result, a Response the handler built, a hook’s short-circuit, and an error response. A request id or a rotated session cookie does not vanish on the 401 it goes with. Headers are merged by name:

  • set-cookie is appended;
  • vary is merged, each token once, so a handler’s Vary: Cookie survives a CORS hook adding Origin;
  • every other name overwrites the response’s own value.

ctx.out.status, by contrast, applies only to a serialized result. An error’s status belongs to the error, and a Response carries its own.

schema.response describes what leaves. A single schema checks every serialized response, whatever its status. A map binds a schema to each status, and is also the list of statuses the route answers with:

const UserId = z.object({ id: z.coerce.number().int().positive() });
const NewUser = z.object({ name: z.string().min(1) });
const PublicUser = z.object({ id: z.number(), name: z.string() });
export const usersController = controller("Users", () => ({
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: "/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: NewUser, response: { 201: PublicUser } },
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;
};
}
ctx
) => {
const
const user: {
id: number;
name: string;
passwordHash: string;
}
user
= {
id: number
id
:
const users: Map<number, StoredUser>
users
.
size: number

@returns ― the number of elements in the Map.

size
+ 1,
name: string
name
:
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;
};
}
ctx
.
body: {
name: string;
}
body
.
name: string
name
,
passwordHash: string
passwordHash
: "…" };
const users: Map<number, StoredUser>
users
.set(
const user: {
id: number;
name: string;
passwordHash: string;
}
user
.
id: number
id
,
const user: {
id: number;
name: string;
passwordHash: string;
}
user
);
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;
};
}
ctx
.
out: Outgoing & DeclaredOutgoing<201>

Response parameters for the serialized handler result.

out
.
status: 201 | undefined
status
= 201;
return
const user: {
id: number;
name: string;
passwordHash: string;
}
user
;
},
}),
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: "/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: UserId,
response: {
readonly 204: null;
}
response
: { 204: null } },
handler: (ctx: {
readonly out: Outgoing & DeclaredOutgoing<204>;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}) => 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 out: Outgoing & DeclaredOutgoing<204>;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx
) => {
if (!
const users: Map<number, StoredUser>
users
.delete(
ctx: {
readonly out: Outgoing & DeclaredOutgoing<204>;
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: number
id
)) throw httpError(404, "USER_NOT_FOUND");
},
}),
}));
  • Only a declared status leaves. Setting an undeclared status on ctx.out.status is a compile error. A response that still leaves with one, such as the implicit 200 of a handler that forgot to set 201, is a 500.
  • null declares a status without a body, such as 204 or 304. A value returned under it is a 500.
  • The status picks the schema. The handler may return any of the declared shapes, and the status it leaves with decides which schema checks it. The wrong shape for the status compiles, and is a 500.

Entries for statuses the handler never returns, such as a 404 it throws, only document the error path. A thrown HttpError is answered by the error mapping, and no response schema is checked there.

The value the schema returns is what becomes the JSON. In the example above, the stored user carries a passwordHash that PublicUser does not name, and Zod’s object schema drops unknown keys, so the hash never leaves. TypeScript alone would not catch this: a StoredUser satisfies { id: number; name: string }, extra field and all.

A response that fails its schema is the server’s fault. It answers 500 with the envelope and nothing of the value, and reportError receives a ResponseContractError with source: "response". See Errors.

validateResponses: false on createApp turns every response check off. The schemas still type the handler and document the route. The framework never reads NODE_ENV itself, so checking outside production only is your call:

const
const app: App<RoutesOf<object[]>>
app
= createApp({
routes: object[]

The topology: a group, a controller, or an array of either.

routes
,
validateResponses?: boolean | undefined

Whether handler results are checked against schema.response at runtime. Defaults to true.

The check is a contract and a transformation: the value the validator returns is what gets serialized, so a schema that strips unknown keys is what stops a field like passwordHash from leaving the process — structural typing cannot, because a handler returning User & { passwordHash } satisfies the declared type. A declared schema behaves identically in every environment; the framework never consults NODE_ENV.

false turns the runtime check off entirely. The schema keeps working for free at the other two levels — it still checks the handler's return type at compile time and still documents the contract. Validating outside production only is a decision for the composition root:

validateResponses: Bun.env.NODE_ENV !== "production"

Handlers returning a raw Response are never checked: the framework does not inspect a response it did not build.

validateResponses
: Bun.
const env: Bun.Env & NodeJS.ProcessEnv & ImportMetaEnv

The environment variables of the process

Defaults to process.env as it was when the current Bun process launched.

Changes to process.env at runtime won't automatically be reflected in the default value. For that, you can pass process.env explicitly.

env
.
NODE_ENV?: string | undefined
NODE_ENV
!== "production",
});

An entry can check headers and cookies too, with the keys body, headers and cookies, and name a body that is not JSON with contentType (below). Any other key is refused at startup, so a misspelled part is not silently left unchecked:

const Order = z.object({ id: z.number(), total: z.number() });
const Created = z.object({ location: 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: "/orders"

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

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<201>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => {
const
const order: {
id: number;
total: number;
}
order
=
const orders: {
create(): {
id: number;
total: number;
};
}
orders
.create();
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing & DeclaredOutgoing<201>

Response parameters for the serialized handler result.

out
.
status: 201 | undefined
status
= 201;
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing & DeclaredOutgoing<201>

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.set("location", `/orders/${
const order: {
id: number;
total: number;
}
order
.
id: number
id
}`);
return
const order: {
id: number;
total: number;
}
order
;
},
});
  • headers sees ctx.out.headers after the handler returns, names in lower case, without set-cookie. Hooks may have added headers too, so the schema should allow keys it does not name.
  • cookies sees each cookie the response sets, by name, with the value as the handler wrote it: opened when signed, "" when deleted.
  • An entry without body has no body, like null, unless it has a contentType.

A response that breaks them is a 500.

A value the handler returns always leaves as JSON. A CSV export, a file or an event stream is a Response the handler builds, and contentType on its entry says what it carries:

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.csv"

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.csv",
schema: { response: { 200: {
contentType: "text/csv"
contentType
: "text/csv", body: z.string() } } },
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: {};
}) => 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(toCsv(
const orders: {
all(): object[];
}
orders
.all()), {
headers?: HeadersInit | undefined
headers
: { "content-type": "text/csv" } }),
});
  • The document describes the status under text/csv instead of application/json, with body as its schema. Without body, by the type alone: "application/pdf", "image/*", or "*/*" for a file of any type.
  • Returning a value for such a status is a compile error, and so is returning nothing: a value would leave as JSON, nothing as an empty body. A type the compiler knows only as a string, in a map declared apart from the route, counts as not JSON; as const keeps the literal. Where the compiler does not see the key at all, as in an entry typed as ResponseEntry, the response is a 500 when responses are validated.
  • Its headers and cookies are documented, not checked: only a Response answers such a status, and a Response is not checked.
  • The type goes alone, "text/csv" and not "text/csv; charset=utf-8": the charset belongs on the Response. route() throws on anything but a bare type or a range.

A redirect is a response like any other. Response.redirect works from a handler or a hook, and cookies set on ctx.out go with it:

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: "/login"

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
: "/login",
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
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
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;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing

Response parameters for the serialized handler result.

out
.
cookies: ResponseCookies

Cookies the response will carry, written by name rather than by serializing a set-cookie value by hand.

The mirror of

Outgoing.headers

, and it stands to ctx.cookies exactly as ctx.out.headers stands to ctx.headers: what arrived on one side, what leaves on the other.

cookies
.set("session",
const sessions: {
open(): string;
}
sessions
.open(), {
httpOnly?: boolean | undefined
httpOnly
: true });
return Response.redirect("/orders", 303);
},
});

That redirect is a Response, so it is not checked and not in the generated document. To have it checked and documented, declare it in the response map, set the status and location on ctx.out, and return nothing:

const SeeOther = z.object({ location: 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: "/orders"

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",
schema: { response: { 303: { headers: SeeOther } } },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<303>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => 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 & DeclaredOutgoing<303>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => {
const
const order: {
id: number;
}
order
=
const orders: {
create(): {
id: number;
};
}
orders
.create();
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<303>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing & DeclaredOutgoing<303>

Response parameters for the serialized handler result.

out
.
status: 303 | undefined
status
= 303;
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<303>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing & DeclaredOutgoing<303>

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.set("location", `/orders/${
const order: {
id: number;
}
order
.
id: number
id
}`);
},
});

A Response the handler builds is sent as it is: no response schema checks it, the response map does not apply to its status, and the generated document does not describe it. ctx.out.headers is still applied, and beforeResponse and afterResponse hooks still see it.

Use it for what JSON is not, such as a file, HTML or a stream. For JSON, return the value, so the contract stays checked and documented.

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: "/reports/: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
: "/reports/: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;
};
}) => Promise<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
: async (
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
) =>
new Response(await
const reports: {
csv(id: string): Promise<string>;
}
reports
.csv(
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
), {
headers?: HeadersInit | undefined
headers
: { "content-type": "text/csv" },
}),
});