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, createNextServerLoader } from "@beignet/next";
import { appPorts } from "@/infra/app-ports";
import { appContext } from "@/server/context";
import { routes } from "@/server/routes";

export const getServer = createNextServerLoader(() =>
  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 — the instrumentation option covered in Request lifecycle, and requestBody.maxBytes for JSON/text contract route bodies. The default request body limit is 1 MiB; requests over the limit return a framework-owned 413 with code PAYLOAD_TOO_LARGE.

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 { createApiRoute } from "@beignet/next";
import { getServer } from "@/server";

export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
  createApiRoute(getServer);

Next App Router requires literal named exports for each HTTP method, so Beignet exposes createApiRoute(getServer) to return those exports while keeping provider startup behind the memoized server loader. 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 Component contexts preserve request headers and synthesize the standard cookie header from Next's cookies() store when headers() does not expose one, so auth providers see the same cookie shape they see in API routes. Export contracts = contractsFromRoutes(routes) from server/routes.ts for OpenAPI. 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";

function resolveRequestTenant(auth: AppContext["auth"]) {
  return auth?.session?.tenantId ? createTenant(auth.session.tenantId) : undefined;
}

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);

    return {
      actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(),
      auth,
      requestId,
      ...trace,
      ports,
      tenant: resolveRequestTenant(auth),
    };
  },
  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 the two service entrypoints used by schedules, outbox drains, tasks, and background work; it receives fresh requestId and trace values per call and typically defaults actor to createServiceActor(...):

// scripts/seed.ts (plain script, top-level await)
await server.runServiceContext({ tenantId: "tenant_demo" }, async (ctx) => {
  await seedDemoData(ctx);
});

Acting as a user from non-HTTP entrypoints

The service input type is app-owned, so entrypoints that act on behalf of a user — AI agents, queue consumers, imports, backfills — extend it instead of hand-assembling a context. Resolve the user's real membership from the database in the caller (never from the caller's claims), pass it through the input, and let the factory build the same shape the request factory builds. The server attaches the gate either way, so policies evaluate the impersonated identity exactly as they would a signed-in request:

export type AppServiceContextInput =
  | {
      tenantId?: string;
      /** Act as this user with their verified membership. */
      asUser?: { id: string; role: WorkspaceRole; name?: string };
    }
  | undefined;

// In the service factory:
service: ({ ports, input, requestId, trace }) => ({
  actor: input?.asUser
    ? createUserActor(input.asUser.id, { displayName: input.asUser.name })
    : createServiceActor("app-service"),
  auth: input?.asUser
    ? { user: { id: input.asUser.id } }
    : null,
  requestId,
  ...trace,
  ports,
  ...(input?.tenantId ? { tenant: createTenant(input.tenantId) } : {}),
  ...(input?.asUser ? { membership: { role: input.asUser.role } } : {}),
}),
// An agent capability acting for a verified user:
const role = await server.ports.members.findRole(workspaceId, userId);
if (!role) throw forbidden();

const ctx = await server.createServiceContext({
  asUser: { id: userId, role },
  tenantId: workspaceId,
});
await appendToPageUseCase.run({ ctx, input });

Never assemble a context object by hand or call gate.attach(...) from app code — the server owns gate attachment and strips hand-attached gates with a dev warning.

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 }). The curried route-group form preserves per-route contract and use-case type checks. 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/core/server";
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/core/server";
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.

Raw routes

Some routes cannot be contracts at all: third-party callback endpoints with externally defined request shapes (a Liveblocks room-auth endpoint, a Better Auth callback), signature-verified webhooks, or streaming endpoints that own body consumption. server.rawRoute(...) builds a handler for these that still runs the whole pipeline — correlation, onRequest/beforeHandle/ beforeSend/afterSend hooks, context creation, instrumentation, and framework error mapping — without contract parsing or validation. The request body is left unconsumed, so the handler can verify a signature over the exact raw bytes.

// app/api/liveblocks-auth/route.ts
import { getServer } from "@/server";

let roomAuth: ((req: Request) => Promise<Response>) | undefined;

export async function POST(req: Request) {
  roomAuth ??= (await getServer())
    .rawRoute({
      name: "collab.roomAuth",
      method: "POST",
      path: "/api/liveblocks-auth",
      metadata: {
        rateLimit: { max: 300, windowSec: 60, scope: "user" },
      },
    })
    .handle(async ({ req, ctx }) => {
      const body = await req.text();
      // Authorize the room from app data and return the provider token.
      return { status: 200, body: { token: "..." } };
    });

  return roomAuth(req);
}

name, method, and path identify the route to hooks, instrumentation, and devtools; routing itself belongs to the file that mounts the handler. metadata feeds metadata-driven hooks exactly like contract metadata, so declared rate limits and idempotency apply without hand-rolled enforcement. The Next.js route factories — createWebhookRoute, createPaymentWebhookRoute, createScheduleRoute, and createOutboxDrainRoute — run through this pipeline automatically when the server they receive exposes rawRoute(...).

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:

import { getServer } from "@/server";

export async function POST(req: Request) {
  const server = await getServer();
  const handle = 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 } };
  });

  return handle(req);
}

export async function GET(req: Request) {
  const server = await getServer();
  const handle = server.route(downloadReport).handle(async () =>
    new Response(await loadReportBytes(), {
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": 'attachment; filename="report.pdf"',
      },
    }),
  );

  return handle(req);
}

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 getServer = createNextServerLoader(() =>
  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. Error reporting uses createErrorReportingHooks(...) to capture unexpected HTTP failures without changing response mapping.

Registration-time guarantees

createServer(...) validates the route registry up front so contract drift fails at startup instead of surfacing as confusing request-time behavior:

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.

Runtime integrity

Routes fail loudly when they are not registered. Workflow artifacts can fail more quietly: a listener, schedule, task, or outbox handler can exist in a feature folder but never make it into the runtime registry that executes it. Use runtime integrity when you want boot to catch that drift:

// server/runtime-integrity.ts
import {
  createRuntimeIntegrity,
  defineRuntimeManifest,
  defineRuntimeRegistries,
} from "@beignet/core/server";
import { postEvents } from "@/features/posts/domain/events";
import { postJobs } from "@/features/posts/jobs";
import { postListeners } from "@/features/posts/listeners";
import { listeners } from "@/server/listeners";
import { outboxRegistry } from "@/server/outbox";

export const runtimeManifest = defineRuntimeManifest({
  listeners: [...postListeners],
  outbox: {
    events: [...postEvents],
    jobs: [...postJobs],
  },
});

export const runtimeRegistries = defineRuntimeRegistries({
  listeners,
  outbox: outboxRegistry,
});

export const runtimeIntegrity = createRuntimeIntegrity({
  manifest: runtimeManifest,
  registries: runtimeRegistries,
});

Then pass it to the server:

createNextServer({
  ports,
  context,
  routes,
  integrity: runtimeIntegrity,
});

The check is pure and serverless-safe: it compares imported definitions and registries in memory. It does not scan the filesystem, call providers, touch databases or queues, start workers, or prove that cron and worker deployments exist. mode: "warn" logs findings instead of failing startup; omitted integrity leaves the server unchanged. beignet doctor --strict remains the static drift check and also warns when an opted-in runtime manifest omits feature workflow registries.