Routes and server
The server runtime matches requests to contracts, validates requests and responses, and runs your use cases with a typed per-request context.
Read this page when you are wiring route groups, choosing the Next.js or Web Fetch adapter, or configuring server options. If you are creating your first app, start with Quickstart. For what happens to a request between arrival and response, see Request lifecycle.
Creating a server
// server/index.ts
import { createNextServer } from "@beignet/next";
import { appPorts } from "@/infra/app-ports";
import { appContext } from "@/server/context";
import { routes } from "@/server/routes";
export const server = await createNextServer({
ports: appPorts,
context: appContext,
routes,
});ports wires your application's dependency interfaces — databases, caches,
mailers — defined with definePorts. See Ports for defining and
deferring ports, and Providers for ready-made implementations.
The server also accepts the mapUnhandledError and onCaughtError error
options — see Errors — and the instrumentation option covered in
Request lifecycle.
Next.js integration
The @beignet/next adapter works with the Next.js App Router. Expose the
central handler from a catch-all API route:
// app/api/[[...path]]/route.ts
import { server } from "@/server";
export const DELETE = server.api;
export const GET = server.api;
export const HEAD = server.api;
export const OPTIONS = server.api;
export const PATCH = server.api;
export const POST = server.api;
export const PUT = server.api;Next App Router requires literal named exports for each HTTP method, so
Beignet exposes one server.api handler that you assign to every verb your
API should accept. The catch-all file belongs to the adapter layer; Beignet
contracts themselves still use concrete paths with optional single-segment
params such as /posts/:id.
The Next adapter also ships helpers for common app glue: createOpenAPIHandler
and Swagger routes (see OpenAPI), Server Component context helpers,
upload routes, storage routes, devtools routes, and outbox drain routes.
server.contracts is populated by createNextServer({ routes }); for
per-file server.route(contract).handle(...) handlers, pass an explicit
contract list instead.
Web Fetch runtimes
Use @beignet/web when the runtime accepts a standard Request and returns a
standard Response — Cloudflare Workers, Bun, Deno, Node fetch servers, and
route tests. createFetchServer(...) takes the same options as
createNextServer and exposes a framework-neutral server.fetch handler; see
the @beignet/web README for
setup.
All adapters share the same boundary: @beignet/core/server owns matching,
hooks, validation, error mapping, and response ownership, while the adapter
only converts between platform request/response types — implement the
HttpAdapter<NativeRequest, NativeResponse> shape (webFetchAdapter is the
reference) to target another runtime.
Context
Put your baseline context type in app-context.ts, then declare a context
blueprint that builds that shape. Feature route groups, use cases, hooks, and
tests should import AppContext from that file instead of redefining it.
// app-context.ts
import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
import type { TraceContext } from "@beignet/core/tracing";
import type { AppGate, AppPorts } from "@/ports";
import type { AuthSession } from "@/ports/auth";
export type AppContext = {
actor: ActivityActor;
auth: AuthSession | null;
gate: AppGate;
requestId: string;
ports: AppPorts;
tenant?: ActivityTenant;
} & Partial<TraceContext>;Keep the runtime blueprint in server/context.ts so the server and route tests
reuse the same context construction:
// server/context.ts
import {
createAnonymousActor,
createServiceActor,
createTenant,
createUserActor,
} from "@beignet/core/ports";
import { defineServerContext } from "@beignet/core/server";
import type { TraceContext } from "@beignet/core/tracing";
import type { AppContext } from "@/app-context";
export type AppServiceContextInput =
| {
tenantId?: string;
}
| undefined;
export const appContext = defineServerContext<
AppContext,
AppContext["ports"]
>()({
gate: (ports) => ports.gate,
request: async ({ ports, req, requestId, trace }) => {
const auth = await ports.auth.getSession(req);
const tenantId = req.headers.get("x-tenant-id") || undefined;
return {
actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(),
auth,
requestId,
...trace,
ports,
tenant: tenantId ? createTenant(tenantId) : undefined,
};
},
service: ({
ports,
input,
requestId,
trace,
}: {
ports: AppContext["ports"];
input: AppServiceContextInput;
requestId: string;
trace: TraceContext;
}) => ({
actor: createServiceActor("app-service"),
auth: null,
requestId,
...trace,
ports,
tenant: createTenant(input?.tenantId ?? "tenant_default"),
}),
});The context option receives this blueprint. The request factory runs on
every request: it receives ports, the raw request, and the server-resolved
requestId and trace values, and returns the context fields available to all
handlers. The server owns ctx.gate: declare which port provides it with
gate, and the server attaches a live gate that always authorizes against the
current actor and tenant — even after hooks elevate identity. Returning
gate from a factory is a compile error.
auth is the resolved provider session or null. actor is the durable
audit and authorization actor derived from that session, or from a service
identity in background contexts. tenant is optional; omit it when no tenant
is active instead of setting it to null.
An optional service factory powers server.createServiceContext(...), the
context used by schedules, outbox drains, and background work; it receives
fresh requestId and trace values per call and typically defaults actor
to createServiceActor(...). Apps without a gate on their context type can
pass a plain request factory:
context: async ({ ports, requestId }) => ({ requestId, ports }).
Route registration
The canonical Beignet app route style: feature route files use
defineRouteGroup<AppContext>({ name, routes }), route entries bind a
contract directly to a use case ({ contract, useCase }), and routes that own
response headers, streaming, native Response values, or multi-status
handling implement a full handler ({ contract, handle }). server/routes.ts
composes groups with defineRoutes<AppContext>(...), and server/index.ts
passes the central routes list to the adapter. This gives route inspection,
OpenAPI, typed clients, lint, and doctor one shared route registry.
// features/todos/routes.ts
import { defineRouteGroup } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { getTodo, listTodos } from "@/features/todos/contracts";
import { getTodoUseCase, listTodosUseCase } from "@/features/todos/use-cases";
export const todoRoutes = defineRouteGroup<AppContext>({
name: "todos",
routes: [
{ contract: listTodos, useCase: listTodosUseCase },
{ contract: getTodo, useCase: getTodoUseCase },
],
});A binder route synthesizes the handler at registration time. The response
status is inferred when the contract declares exactly one 2xx response;
multiple 2xx responses require an explicit status. The use case input
defaults to a merge of the parsed request parts, and use-case errors flow
through the app error catalog exactly as they do from handle routes.
Share schemas by reference and Beignet skips double validation: when a
contract's single input source schema, or its declared success response
schema, is the same object as the use case's .input(...) or .output(...)
schema, the redundant second parse is skipped. Reusing schemas by reference is
how the framework knows validation already happened.
The default input mapping — query lowest, then body, then path, headers never
merged — is exported as defaultBinderInput. Routes that read headers, or
need any other input shape, declare an explicit input mapper over the parsed
parts:
{
contract: resolveIssue,
hooks: [writerAuth.required()],
useCase: resolveIssueUseCase,
input: ({ path, headers }) => ({
key: path.key,
expectedVersion: headers["x-expected-version"],
}),
},Full handlers
handle remains the escape hatch for everything the binder intentionally does
not cover — response headers, streaming, native Response values, redirects,
and multi-status handling:
// features/todos/routes.ts
{
contract: exportTodos,
handle: async ({ ctx, query }) => {
const csv = await exportTodosUseCase.run({ ctx, input: query });
return new Response(csv, {
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="todos.csv"',
},
});
},
},The handler object gives you req (the raw HTTP request), ctx, the
validated path, query, headers, and body parts, and contract for
metadata access — all typed from the contract definition. Declared request
headers are normalized to lowercase before validation: use
headers.authorization for parsed contract headers and req.headers for raw
transport access.
Compose feature groups at the server boundary:
// server/routes.ts
import { contractsFromRoutes, defineRoutes } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { todoRoutes } from "@/features/todos/routes";
export const routes = defineRoutes<AppContext>([todoRoutes]);
export const contracts = contractsFromRoutes(routes);defineRoutes flattens route groups before they are passed to the server, so
server/index.ts can stay focused on app composition.
Focused per-file routes
Use server.route(contract).handle(...) for focused per-file adapter routes —
webhooks, redirects, downloads, OpenAPI/devtools — that intentionally sit
outside the central route registry. Export an explicit contract list when such
a route should appear in OpenAPI or typed-client contract lists.
Beyond JSON
Beignet is JSON-first: returning a plain object from a handler produces a JSON
response and declared response schemas validate that JSON value. For
transport-level cases such as webhook signatures, downloads, plain text,
redirects, or streams, use the raw request readers and return a native web
Response:
export const POST = server.route(stripeWebhook).handle(async ({ req }) => {
const rawBody = await req.text();
const signature = req.headers.get("stripe-signature");
verifyWebhookSignature(rawBody, signature);
return { status: 200, body: { received: true } };
});
export const GET = server.route(downloadReport).handle(async () =>
new Response(await loadReportBytes(), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="report.pdf"',
},
}),
);
export const GET = server.route(robotsTxt).handle(async () =>
new Response("User-agent: *\nAllow: /\n", {
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
);
export const POST = server.route(startCheckout).handle(async () =>
Response.redirect("https://checkout.example.com/session/123", 303),
);Native Response instances are transport-owned: they bypass JSON response
validation, and beforeSend hooks only merge header changes onto them. Use
{ status, body } when you want the response contract enforced; use
Response when the route owns transport details directly. See
Request lifecycle for the ownership
taxonomy and Hooks for the native-response hook rules.
A single Set-Cookie header works in the framework-neutral
{ status, headers, body } form, so the route keeps response validation and
correlation headers. To set multiple cookies in one response — HTTP requires
one Set-Cookie header per cookie — return a native Response and
headers.append("set-cookie", ...) each one. Auth providers such as Better
Auth own their cookie flows through their own mounted routes, so most apps
never set cookies from contract routes.
Document binary or streaming transport-owned routes with
.responses({ 200: null }) and an OpenAPI media override such as
application/octet-stream or text/event-stream; call them with platform
fetch when the caller needs bytes or a stream.
Response validation
Beignet automatically validates incoming requests against your contract
schemas. If validation fails, it returns a framework-owned 422 response whose
body identifies the contract, method, path, failing location (path, query,
headers, or body), and schema issues — see Errors
for the envelope. Your handler only runs if the request is valid.
It also validates outgoing handler responses against contract.responses. If
a handler returns an undeclared status or a body that does not match the
declared schema, Beignet returns a 500 contract-violation response instead of
silently drifting from the contract. Response violations identify the returned
status and the declared statuses, but never echo the invalid body, so handlers
cannot leak route-owned data while reporting drift. Pass
validateResponses: false to createServer(...) to skip route-owned response
validation, mirroring the typed client's validateResponses option.
Production posture. Response validation is on by default and costs one schema parse per response on your hottest routes. Keep it on in development, CI, and tests, where it catches contract drift the moment it happens. If profiling shows it is a measurable cost in production, drive it from env so only production trades the guarantee for throughput:
// lib/env.ts declares VALIDATE_RESPONSES as a boolean defaulting to true.
export const server = await createNextServer({
// ...
validateResponses: env.VALIDATE_RESPONSES,
});Binder routes whose use case .output(...) schema is the same object as the
declared success response schema already skip the redundant success-status
parse, so for them this knob only affects error and undeclared statuses.
Validation responses are one kind of framework-owned response. For the full
route-owned / framework-owned / transport-owned taxonomy and the
x-beignet-error-owner header, see
Request lifecycle. For the app error
catalog, AppError, and unhandled-error mapping, see Errors.
Hooks
Server hooks wrap every request for protocol and lifecycle behavior; route
hooks attach beside contracts in feature route groups for auth, tenancy, and
feature-specific preconditions. See Hooks for lifecycle order,
createAuthHooks, and typing hook-enriched context with defineRoute.
Logging and Rate limiting are production
patterns built on hooks.
Registration-time guarantees
createServer(...) validates the route registry up front so contract drift
fails at startup instead of surfacing as confusing request-time behavior:
- Unique method + path. Registering the same method and path twice throws,
and dynamic paths that differ only by parameter name (
/items/:idvs/items/:slug) are rejected as ambiguous. - Unique contract names. Typed clients, OpenAPI operations, and devtools key on contract names, so two contracts with the same name cannot both be registered even on different paths.
- Path templates match
pathParams. When a contract declares an introspectablepathParamsobject schema, its keys must match the:paramkeys in the path template; missing or extra keys throw at startup with the contract name and path. Non-introspectable Standard Schemas skip this check. - Body schemas require a body method. Request body schemas are supported
for
POST,PUT, andPATCHcontracts; attaching one toGET,HEAD,DELETE, orOPTIONSis rejected during registration.
These checks apply both to the routes list passed to createServer(...) and
to imperative server.route(contract).handle(...) registration. Runtime
dispatch — match specificity, 405 with Allow, and 404 — is covered in
Request lifecycle.