Server

The server runtime handles HTTP requests with full type safety, automatic request/response validation, and explicit request lifecycle hooks.

Creating a server

import { createNextServer } from "@beignet/next";
import {
  createAnonymousActor,
  createTenant,
  createUserActor,
  definePorts,
} from "@beignet/core/ports";

const appPorts = definePorts({
  todos: {
    findById: async (id: string) => ({ id, title: "Example", completed: false }),
  },
});

export const server = await createNextServer({
  ports: appPorts,
  createContext: ({ ports, req }) => {
    // DEMO ONLY: this reads unauthenticated headers to simulate identity.
    // Real applications should verify a signed token or session cookie first.
    const userId = req.headers.get("x-user-id") || undefined;
    const tenantId = req.headers.get("x-tenant-id") || undefined;

    return {
      actor: userId ? createUserActor(userId) : createAnonymousActor(),
      tenant: tenantId ? createTenant(tenantId) : undefined,
      requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
      ports,
    };
  },
  mapUnhandledError: ({ err }) => ({
    status: 500,
    body: {
      code: "INTERNAL_SERVER_ERROR",
      message: "Internal server error",
      ...(err instanceof Error
        ? { details: { message: err.message } }
        : {}),
    },
  }),
});

Ports

Ports define your application's external dependencies — databases, caches, mailers, etc. Using definePorts gives you full type inference throughout your handlers.

import { definePorts } from "@beignet/core/ports";

export const appPorts = definePorts({
  todos: { /* your repository adapter */ },
  cache: { /* your cache adapter */ },
  mailer: { /* your mail adapter */ },
});

See Providers for ready-made implementations.

Context

The createContext function runs on every request. It receives ports and the raw request, and returns a context object available to all handlers.

createContext: ({ ports, req }) => {
  // DEMO ONLY: this reads unauthenticated headers to simulate identity.
  // Real applications should verify a signed token or session cookie first.
  const userId = req.headers.get("x-user-id") || undefined;
  const tenantId = req.headers.get("x-tenant-id") || undefined;

  return {
    actor: userId ? createUserActor(userId) : createAnonymousActor(),
    tenant: tenantId ? createTenant(tenantId) : undefined,
    requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
    ports,
  };
},

Route registration

In Beignet apps, keep the central route registry in server/routes.ts with defineRoutes. Feature route groups stay close to their feature, while the server imports the single routes list for runtime wiring.

import {
  defineRouteGroup,
  defineRoutes,
} from "@beignet/next";
import { getTodo } from "@/features/todos/contracts";
import { appError } from "@/features/shared/errors";

type AppContext = {
  requestId: string;
  ports: {
    todos: {
      findById: (id: string) => Promise<unknown>;
    };
  };
};

export const routes = defineRoutes<AppContext>([
  {
    contract: getTodo,
    handle: async ({ ctx, path }) => {
      const todo = await ctx.ports.todos.findById(path.id);

      if (!todo) {
        throw appError("TodoNotFound", { details: { id: path.id } });
      }

      return { status: 200, body: todo };
    },
  },
]);

The handler object gives you:

All values are fully typed based on the contract definition.

Declared request headers are normalized to lowercase before validation. Use headers.authorization for parsed contract headers and req.headers when you need raw transport access.

Route groups

As an app grows, keep feature route wiring close to the feature and compose groups at the server boundary.

// features/todos/routes.ts
import { defineRouteGroup } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { createTodo } from "@/features/todos/contracts";

export const todoRoutes = defineRouteGroup<AppContext>({
  name: "todos",
  routes: [
    {
      contract: createTodo,
      handle: async ({ ctx, body }) => {
        const todo = await ctx.ports.todos.create({
          title: body.title,
          completed: body.completed ?? false,
        });

        return { status: 201, body: todo };
      },
    },
  ],
});
// server/routes.ts
import { contractsFromRoutes, defineRoutes } from "@beignet/next";
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 route matching stays explicit while server/index.ts can stay focused on app composition:

import { createNextServer } from "@beignet/next";
import { appPorts } from "@/infra/app-ports";
import { routes } from "./routes";

export const server = await createNextServer({
  ports: appPorts,
  createContext: ({ ports }) => ({
    requestId: crypto.randomUUID(),
    ports,
  }),
  routes,
});
routes: defineRoutes<AppContext>([
  {
    contract: createTodo,
    handle: async ({ ctx, body }) => {
      const todo = await ctx.ports.todos.create({
        title: body.title,
        completed: body.completed ?? false,
      });

      return { status: 201, body: todo };
    },
  },
]),

For OpenAPI, devtools, webhooks, or another focused endpoint, you can implement a single Next.js route with server.route(contract).handle(...).

Raw requests and non-JSON responses

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 outside JSON response validation. Use { status, body } when you want the response contract enforced; use Response when the route owns transport details directly. Response-shaping hooks such as beforeSend run for plain Beignet responses, including framework-owned errors, while observation hooks such as afterSend receive the final status and headers.

Route matching

Routes are matched by HTTP method and path. Static segments are more specific than dynamic segments, so /posts/new wins over /posts/:slug regardless of registration order.

Dynamic parameter names do not affect matching. Registering both /items/:id and /items/:slug for the same method throws an ambiguity error because both routes match the same URLs.

Hooks

Request lifecycle hooks

Add hooks that can short-circuit before parsing, prepare context-aware handler state, shape the final response, or observe completion. See Hooks for lifecycle order, metadata-driven hooks, and response ownership details. See Logging and Rate limiting for production patterns built on hooks.

import {
  createLoggingHooks,
} from "@beignet/core/server";

const logging = createLoggingHooks({
  logger: console,
  requestIdHeader: "x-request-id",
});

const authHooks = {
  name: "auth",
  beforeHandle: async ({ req, ctx, contract }) => {
    if (contract.metadata?.auth !== "required") {
      return;
    }

    const token = req.headers.get("authorization");
    if (!token) {
      return {
        ctx,
        response: {
          status: 401,
          body: { code: "UNAUTHORIZED", message: "Unauthorized" },
        },
      };
    }
  },
};

export const server = await createNextServer({
  ports: appPorts,
  hooks: [logging, authHooks],
  createContext: ({ ports }) => ({ ports }),
});

This works because contracts can declare metadata like .meta({ auth: "required" }). Early onRequest hooks see the raw request, matched params, available ports, and route metadata before parsing or createContext. beforeHandle hooks run after validation and context creation, so they can enrich ctx, enforce user-scoped policies, or short-circuit before the handler.

Error handling

Automatic validation

Beignet automatically validates incoming requests against your contract schemas. If validation fails, it returns a 422 response with structured error details. 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 response with a contract-violation error instead of silently drifting from the contract.

When validation fails, the response body contains structured error details:

{
  "code": "VALIDATION_ERROR",
  "message": "Invalid request body",
  "details": {
    "issues": [
      { "path": ["title"], "message": "Required" },
      { "path": ["completed"], "message": "Expected boolean, received string" }
    ]
  }
}

Response ownership

Beignet treats responses as route-owned, framework-owned, or transport-owned.

Route-owned responses are validated against contract.responses when the route declares response schemas:

If either returns an undeclared status or a body that does not match the declared schema, Beignet returns a 500 contract-violation response. If contract.responses is empty, no response validation is applied.

Request body schemas are supported for POST, PUT, and PATCH contracts only. Routes that attach a body schema to GET, HEAD, DELETE, or OPTIONS are rejected during registration.

Framework-owned responses skip route response validation and use Beignet's standard error envelope when applicable:

Framework-owned Beignet error envelopes include x-beignet-error-owner: framework so generated clients can distinguish framework errors from route-owned error responses that share the same status code.

Transport-owned responses are native Response objects returned from handlers or hooks. They bypass JSON response validation and beforeSend, but afterSend still observes their status and headers. Use them for non-JSON payloads, redirects, streaming, and Server-Sent Events.

This keeps the contract strict for business responses while letting infrastructure concerns such as auth, CORS, rate limits, malformed requests, and unexpected failures stay outside each route's response union.

Custom errors

Use the app error catalog for route-owned business errors. Declare expected catalog errors on the contract with .errors(...), then throw appError(...) from handlers or use cases.

export const getTodo = todos
  .get("/:id")
  .responses({ 200: TodoSchema })
  .errors({ TodoNotFound: errors.TodoNotFound });

export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
  const todo = await ctx.ports.todos.findById(path.id);

  if (!todo) {
    throw appError("TodoNotFound", { details: { id: path.id } });
  }

  return { status: 200, body: todo };
});

Route-owned errors may still use any schema you declare with .responses(...), but the catalog gives clients stable code narrowing and keeps app errors in one place.

Error chaining with cause

Use AppError to throw structured HTTP errors. The cause option preserves the original error for debugging.

import { AppError, httpErrors } from "@beignet/core/errors";

try {
  await db.query(...);
} catch (dbError) {
  throw new AppError(
    httpErrors.InternalServerError,
    { table: "todos" },
    "Database query failed",
    { cause: dbError },
  );
}

The cause is available via error.cause and will appear in server logs but is never exposed to the client.

Error observation and mapping

Use onCaughtError for logging, metrics, and tracing. It observes caught failures without changing response behavior. The server-level mapUnhandledError callback maps unknown or otherwise unhandled exceptions after declared AppError instances are auto-mapped.

onCaughtError: ({ err, req, ctx }) => {
  console.error("Caught error:", err);
  console.error("Request:", req.method, req.url);
  console.error("Context:", ctx);
},
mapUnhandledError: ({ err, req, ctx }) => {
  return {
    status: 500,
    body: {
      code: "INTERNAL_SERVER_ERROR",
      message: "Internal server error",
    },
  };
},

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;

The Next adapter also includes helpers for common app glue:

// app/api/openapi/route.ts
import { createOpenAPIHandler } from "@beignet/next";
import { server } from "@/server";

export const GET = createOpenAPIHandler(server.contracts, {
  title: "My API",
  version: "1.0.0",
});

// lib/api-client.ts
import { createNextClient } from "@beignet/next";

export const client = createNextClient();

server.contracts is populated by createNextServer({ routes }). For per-file server.route(contract).handle(...) handlers, pass an explicit contract list or an exported route registry instead.