Beignet API reference
    Preparing search index...

    Module @beignet/next

    @beignet/next

    Caution

    Beignet is experimental alpha software. The 0.0.x package line is for early evaluation, and APIs may change between releases while the framework settles.

    Next.js adapter for the framework-agnostic @beignet/core/server runtime. It builds on @beignet/web for standard Request/Response handling and adds Next-specific helpers for App Router handlers, Server Component context, OpenAPI routes, Swagger UI, uploads, payment webhooks, outbox drains, storage routes, and client base URLs.

    Use @beignet/next for Next.js applications. Use @beignet/web directly when the runtime already accepts standard Web Fetch Request/Response objects and does not need Next-specific route helpers. Both adapters share the same framework-neutral core boundary: core owns route matching, hooks, validation, errors, response ownership, and provider lifecycle; adapters own platform request/response conversion.

    npm install @beignet/next @beignet/core next
    
    • @beignet/core/openapi for OpenAPI documentation
    • @beignet/core/ports if you want to define shared ports explicitly in your app

    This package requires TypeScript 5.0 or higher for proper type inference.

    This package ships a TanStack Intent skill for coding agents: @beignet/next#routes-server. Load it when wiring route groups, central route registration, createNextServer, server context, OpenAPI/devtools routes, Next catch-all API adapters, uploads, storage routes, webhooks, schedule cron routes, or outbox drain routes.

    // features/todos/contracts.ts
    import { defineContractGroup } from "@beignet/core/contracts";
    import { z } from "zod";

    const todos = defineContractGroup()
    .namespace("todos")
    .prefix("/api/todos");

    export const getTodo = todos
    .get("/:id")
    .pathParams(z.object({ id: z.string() }))
    .responses({ 200: z.object({
    id: z.string(),
    title: z.string(),
    completed: z.boolean(),
    }) });

    This adapter quick start uses a tiny demo context. Production Beignet apps should use the canonical AppContext from the docs and generated starter: requestId, actor, auth, gate, ports, and optional tenant.

    // app-context.ts
    export type AppContext = {
    userId: string;
    };

    Bind route declarations to that context once:

    // lib/routes.ts
    import "@beignet/core/server-only";
    import { createRoutes } from "@beignet/core/server";
    import type { AppContext } from "@/app-context";

    export const { defineRoute, defineRouteGroup } = createRoutes<AppContext>();
    // features/todos/routes.ts
    import { defineRouteGroup } from "@/lib/routes";
    import { getTodo } from "@/features/todos/contracts";

    export const todoRoutes = defineRouteGroup({
    name: "todos",
    routes: [
    {
    contract: getTodo,
    handle: async ({ path }) => ({
    status: 200,
    body: {
    id: path.id,
    title: "Example todo",
    completed: false,
    },
    }),
    },
    ],
    });

    For ordinary app routes, route entries live near the feature and bind a contract to a use case ({ contract, useCase }); the full { contract, handle } form shown above is the escape hatch for demo stubs and routes that own headers, streaming, or multi-status responses. Compose those groups once in server/routes.ts:

    // 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);
    // server/index.ts
    import { createNextServer, createNextServerLoader } from "@beignet/next";
    import type { AppContext } from "@/app-context";
    import { routes } from "@/server/routes";

    export const getServer = createNextServerLoader(() =>
    createNextServer<AppContext>({
    ports: {},
    routes,
    context: async ({ req }) => {
    // DEMO ONLY: this reads an unauthenticated header to simulate identity.
    // Real applications should verify a signed token or session cookie first.
    return {
    userId: req.headers.get("x-user-id") || "anonymous",
    };
    },
    mapUnhandledError: () => ({
    status: 500,
    body: {
    code: "INTERNAL_SERVER_ERROR",
    message: "Internal server error",
    },
    }),
    }),
    );

    Expose ordinary application routes through one catch-all framework route. Next App Router imports route modules during production builds, so keep server boot behind the memoized getServer loader and expose literal named exports with createApiRoute:

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

    This catch-all file is Next.js adapter glue. It forwards requests to the Beignet server, but individual contracts should still use explicit paths with single-segment params such as /posts/:id, not catch-all contract patterns such as /files/[...path].

    Use focused helpers or per-file server.route(contract).handle(...) handlers for endpoints that intentionally sit outside the central route registry, such as webhooks, redirects, downloads, or adapter-specific glue:

    // app/api/webhooks/payments/route.ts
    import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
    import { getServer } from "@/server";
    import { createPaymentWebhookRoute } from "@beignet/next";

    export const runtime = "nodejs";

    export const { POST } = createPaymentWebhookRoute({
    server: getServer,
    handle: async ({ ctx, event }) => {
    await handlePaymentWebhookUseCase.run({ ctx, input: event });
    return { status: 200, body: { received: true } };
    },
    });

    @beignet/next exposes the underlying web Request through HttpRequestLike. Use createWebhookRoute(...) for provider webhooks because it reads the raw request body, passes normalized headers into your verifier, and validates the typed event catalog before your app handles the event.

    export const { POST } = createWebhookRoute({
    server: getServer,
    webhook: providerWebhook,
    handle: async ({ ctx, event }) => {
    await handleProviderEventUseCase.run({ ctx, input: event.payload });
    return { status: 200, body: { received: true } };
    },
    });

    For downloads, plain text, and redirects, return a native web Response:

    import { getServer } from "@/server";

    export async function GET(req: Request) {
    const server = await getServer();
    const handle = server.route(downloadFile).handle(async () =>
    new Response(await loadFile(), {
    headers: { "Content-Type": "application/octet-stream" },
    }),
    );

    return handle(req);
    }

    export async function POST(req: Request) {
    const server = await getServer();
    const handle = server.route(startCheckout).handle(async () =>
    Response.redirect("https://checkout.example.com/session/123", 303),
    );

    return handle(req);
    }

    Native Response instances intentionally bypass JSON serialization and response schema validation. Use { status, body } when you want Beignet to validate a JSON response; use Response when you want full transport control. Response-shaping hooks such as beforeSend only run for plain Beignet responses; observation hooks such as afterSend still receive the final status and headers.

    Creates a Next.js server instance with the given options.

    Parameters:

    • options: Same as createServer from @beignet/core/server:
      • ports: Required - Ports object defining available service interfaces
      • context: Required - Context blueprint. Pass a plain request factory for gate-less contexts, or { gate, request, service } when the context type declares a gate. The server attaches ctx.gate itself; service powers server.createServiceContext(...)
      • mapUnhandledError: Error handler function
      • routes?: Array of route configurations (contract + handler)
      • hooks?: Optional ordered server hooks
      • providers?: Optional array of service providers
      • providerEnv?: Optional environment variables for providers
      • providerConfig?: Optional provider configuration overrides

    Returns: Promise<NextServer<Ctx>>

    A Next.js catch-all route helper for routes registered in server/index.ts. Framework-style apps usually expose it once 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);

    This route file is not a catch-all contract. Keep contract paths explicit and use the file only to expose the central server handler.

    Next App Router requires literal named exports for each HTTP method. The route helper keeps those exports literal while deferring provider startup until a request arrives.

    Returns a route builder for focused per-file handlers such as webhooks, redirects, downloads, or other adapter-owned endpoints. Use defineRouteGroup({ ... }) plus defineRoutes(...) for ordinary application routes; server.route(contract).handle(...) route files are not imported by the central API handler.

    Returns: Route builder with:

    • handle(fn): Create a custom handler function
    // app/api/reports/[id]/download/route.ts
    import { getServer } from "@/server";
    import { downloadReport } from "@/features/reports/contracts";

    export async function GET(req: Request) {
    const server = await getServer();
    const handle = server.route(downloadReport).handle(async ({ ctx, path }) =>
    new Response(await ctx.ports.reports.loadBytes(path.id), {
    headers: { "Content-Type": "application/pdf" },
    }),
    );

    return handle(req);
    }

    Builds a handler for a route that cannot be a contract — third-party callback endpoints with externally defined request shapes, signature-verified webhooks, streaming endpoints — that still runs the whole server pipeline: correlation, hooks (rate limiting, idempotency, CORS, logging, error reporting), context creation, instrumentation, and framework error mapping. Request parsing and validation are skipped and the request body is left unconsumed, so the handler owns body reading.

    init.name, init.method, and init.path identify the route to hooks, instrumentation, and devtools — routing itself belongs to the route file that mounts the handler. init.metadata feeds metadata-driven hooks exactly like contract metadata.

    Returns: Builder with handle(fn) returning (req: Request) => Promise<Response>.

    // 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();
    return { status: 200, body: { token: "..." } };
    });

    return roomAuth(req);
    }

    The webhook, payment webhook, schedule, and outbox drain route factories run through this pipeline automatically when the server they receive exposes rawRoute(...); their pipeline option supplies the route identity and metadata.

    Creates a context object from Next.js Server Components by automatically extracting headers and cookies. This allows you to call use cases directly from React Server Components without going through API routes.

    Returns: Promise<Ctx> - Your fully assembled app context

    For repeated Server Component and layout access, wrap it in a cached app helper:

    // lib/server-context.ts
    import "@beignet/core/server-only";

    import { cache } from "react";
    import { getServer } from "@/server";

    export const getAppRequestContext = cache(async () => {
    const server = await getServer();
    return server.createContextFromNext();
    });
    // app/my-page/page.tsx
    import { getTodoUseCase } from "@/features/todos/use-cases/get-todo";
    import { getAppRequestContext } from "@/lib/server-context";

    export const dynamic = "force-dynamic";

    export default async function MyPage() {
    const ctx = await getAppRequestContext();

    const todo = await getTodoUseCase.run({
    ctx,
    input: { id: "123" },
    });

    return <div>{todo.title}</div>;
    }

    This method:

    • Automatically calls Next.js's headers() and cookies() functions
    • Creates a minimal Request-like object with headers and cookies access. When headers() does not expose a standard cookie header, Beignet synthesizes one from cookies().getAll() so auth providers that read req.headers.get("cookie") behave the same way they do in API routes.
    • Delegates to server.createRequestContext(req) so the request context factory and gate attachment run exactly like API route handlers
    • Returns the same context type you get in API route handlers
    • Uses the HTTP method "GET" for the internal Request-like object. If your request context factory inspects req.method, it will always see "GET" when invoked via createContextFromNext().
    • The req.url is set to a placeholder (http://core/server-component.invalid) since Server Components don't have real HTTP URLs
    • The req.json() and req.text() methods return empty values since there's no actual HTTP request body in Server Components

    Note: This method can only be called from Next.js Server Components (not in Client Components or during build time).

    Builds a fully assembled request context from a framework-neutral HttpRequestLike. Use it for adapter entry points outside the route pipeline.

    Builds a service context through the service factory declared in the context blueprint. Use it for schedules, outbox drains, commands, and background work:

    // server/schedules.ts
    import { createServiceActor } from "@beignet/core/ports";
    import { getServer } from "./index";

    export async function createScheduleContext() {
    const server = await getServer();

    return server.createServiceContext({
    actor: createServiceActor("beignet-schedule"),
    });
    }

    Calling it without a declared context.service factory throws.

    Stops the server and cleans up resources (closes provider connections, etc.).

    await server.stop();
    

    When using .handle(), your handler function receives an object with:

    {
    req: HttpRequestLike, // Raw request object
    ctx: Ctx, // Your custom context from the context blueprint
    path: PathParams, // Validated path parameters
    query: QueryParams, // Validated query parameters
    body: Body, // Validated request body
    contract: Contract, // Resolved contract metadata and schemas
    }

    Beignet promotes clean architecture by separating use cases from HTTP concerns. Call use cases from handlers so the HTTP layer stays explicit:

    // features/todos/use-cases/get-todo.ts
    export async function getTodoUseCase(
    input: { id: string },
    ports: AppPorts
    ) {
    return await ports.db.todos.findById(input.id);
    }

    // app/api/todos/[id]/route.ts
    export const GET = server
    .route(getTodo)
    .handle(async ({ ctx, path }) => {
    const todo = await getTodoUseCase({ id: path.id }, ctx.ports);

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

    Hooks can be added at the server level:

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

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

    export const getServer = createNextServerLoader(() =>
    createNextServer({
    ports: {},
    hooks: [logging],
    context: async () => ({}),
    mapUnhandledError: () => ({
    status: 500,
    body: {
    code: "INTERNAL_SERVER_ERROR",
    message: "Internal server error",
    },
    }),
    }),
    );

    createNextServer(...) accepts the core integrity option. Pass a createRuntimeIntegrity(...) check from @beignet/core/server when a Next app should fail cold start if app-declared listeners, schedules, tasks, or outbox handlers are missing from the runtime registries. The check is pure and serverless-safe; it does not scan files, connect to providers, or start background work during route-module import.

    If you have @beignet/core/openapi installed, use createOpenAPIHandler for a Next.js route. Pass explicit servers from app configuration for deployed docs; request-origin inference is available only when you opt in.

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

    export const GET = createOpenAPIHandler(contracts, {
    title: "My API",
    version: "1.0.0",
    servers: [{ url: env.APP_URL }],
    });

    Export contracts = contractsFromRoutes(routes) from server/routes.ts so the OpenAPI route can stay static and avoid booting providers during Next builds. If you export per-file Next handlers with server.route(contract).handle(...), keep an explicit contract list because those route files are not imported by the server automatically.

    You can also serve Swagger UI without writing the HTML route by hand:

    // app/api/docs/route.ts
    import { createSwaggerUIHandler } from "@beignet/next";

    export const GET = createSwaggerUIHandler({
    title: "My API Documentation",
    specUrl: "/api/openapi",
    });

    Use createStorageRoute to serve public objects from a StoragePort in a Next.js App Router route. The route streams object bodies and maps missing objects, private objects, invalid keys, and paths outside basePath to 404.

    // app/storage/[...key]/route.ts
    import { createStorageRoute } from "@beignet/next";
    import { getServer } from "@/server";

    export const { GET, HEAD } = createStorageRoute(
    async () => (await getServer()).ports.storage,
    {
    basePath: "/storage",
    },
    );

    Served responses preserve object Content-Type, Cache-Control, Content-Length, and Last-Modified headers when available. They also set X-Content-Type-Options: nosniff. By default, active content types such as HTML, SVG, XML, and JavaScript are served with Content-Disposition: attachment; use contentDisposition: "inline" or a custom headers value only when the app intentionally serves active public assets from this route.

    Use createUploadRoute to expose a Beignet upload router from a focused App Router route:

    // app/api/uploads/[uploadName]/[action]/route.ts
    import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
    import { createUploadRouter } from "@beignet/core/uploads";
    import { createUploadRoute } from "@beignet/next";
    import { postUploads } from "@/features/posts/uploads";
    import { getServer } from "@/server";

    export const { POST } = createUploadRoute(async () => {
    const server = await getServer();

    return createUploadRouter({
    uploads: postUploads,
    ctx: () => server.createContextFromNext(),
    storage: server.ports.storage,
    instrumentation: resolveProviderInstrumentationPort(server.ports),
    });
    });

    The action segment must be prepare, upload, or complete.

    Framework-owned operational failures from upload, webhook, payment-webhook, schedule, and outbox-drain helpers use the flat Beignet error body:

    {
    "code": "WEBHOOK_VERIFICATION_FAILED",
    "message": "Webhook verification failed."
    }

    Schedule context such as scheduleName is placed under details. Success payloads keep their existing operation-specific shapes.

    Use createPaymentWebhookRoute(...) for billing flows backed by ctx.ports.payments. Use createWebhookRoute(...) for generic inbound webhooks backed by a defineWebhook(...) catalog and a provider verifier. The server option accepts any object exposing createRequestContext — a NextServer, a core ServerInstance, or a test fake — so server: getServer keeps working unchanged.

    When the server also exposes rawRoute(...) — real Beignet servers do — the route runs inside the full hooks pipeline: rate limiting, CORS, logging, error reporting, and instrumentation apply, the request appears in devtools, and the pipeline option supplies the route identity and metadata for metadata-driven hooks. The raw body stays unconsumed until the route reads it, so signature verification still sees the exact bytes. Minimal test fakes without rawRoute keep the direct flow: raw body first, then app context through server.createRequestContext(...).

    // app/api/webhooks/github/route.ts
    import { githubWebhook } from "@/features/integrations/webhooks";
    import { handleGitHubWebhookUseCase } from "@/features/integrations/use-cases";
    import { env } from "@/lib/env";
    import { getServer } from "@/server";
    import { createWebhookRoute } from "@beignet/next";
    import { createGitHubWebhookVerifier } from "@beignet/webhooks-github";

    export const runtime = "nodejs";

    const githubWebhookVerifier = createGitHubWebhookVerifier({
    secret: () => env.GITHUB_WEBHOOK_SECRET,
    });

    export const { POST } = createWebhookRoute({
    server: getServer,
    webhook: githubWebhook,
    verify: ({ input }) => githubWebhookVerifier.verify(input),
    handle: async ({ ctx, event }) => {
    await handleGitHubWebhookUseCase.run({ ctx, input: event });
    return {
    status: 200,
    body: { received: true },
    };
    },
    });

    Body read failures return 400 before context creation runs. Verification failures return 400 so providers do not treat an invalid signature as a fulfilled event. Context creation failures return 500. Handler failures return 500 so at-least-once webhook providers can retry.

    Use provider verifiers such as createGitHubWebhookVerifier(...) from @beignet/webhooks-github or createStripeWebhookVerifier(...) from @beignet/webhooks-stripe in the route or server layer. Use the context-aware verify option when verification depends on app ports. Generic webhook routes reject verified unknown event types by default; set allowUnknownEvents: true only for broad provider endpoints that intentionally acknowledge valid event types the app does not handle.

    export const { POST } = createWebhookRoute({
    server: getServer,
    webhook: githubWebhook,
    verify: ({ input }) => githubWebhookVerifier.verify(input),
    allowUnknownEvents: true,
    handle: async ({ event }) => {
    if (event.type !== "issues") {
    return { status: 200, body: { ignored: true } };
    }

    return { status: 200, body: { received: true } };
    },
    });

    createPaymentWebhookRoute(...) is the canonical shortcut for payment-port billing flows and the route generated by beignet make payments.

    Use createOutboxDrainRoute to expose durable outbox delivery from a cron or scheduled serverless route. The helper requires a bearer secret, builds app context from the real request with server.createRequestContext(...), drains one bounded batch with @beignet/core/outbox, records a drain summary into the instrumentation port resolved from ctx.ports (ports.instrumentation, then ports.devtools), passes request correlation fields to outbox instrumentation, and returns a JSON summary.

    // app/api/cron/outbox/drain/route.ts
    import { createOutboxDrainRoute } from "@beignet/next";
    import { env } from "@/lib/env";
    import { getServer } from "@/server";
    import { outboxRegistry } from "@/server/outbox";

    export const runtime = "nodejs";

    export const { GET, POST } = createOutboxDrainRoute({
    server: getServer,
    registry: outboxRegistry,
    secret: env.CRON_SECRET,
    batchSize: 100,
    });

    Export both GET and POST when you want the route to work with schedulers that call either method. Export only the method your scheduler uses when you want a narrower route surface.

    Call the route from your scheduler with:

    Authorization: Bearer 
    

    The bearer secret is checked with a timing-safe comparison, and a missing secret fails closed with a 500 response. Authentication runs before resolving the server or creating application context, so rejected cron traffic cannot initialize request-scoped dependencies.

    Do not start long-running outbox polling loops from provider lifecycle hooks in serverless apps.

    On Next.js 15.1 or newer, combine the recovery route with push-assisted polling. createNextOutboxDrainTrigger(...) accepts after as an injected deferred-work scheduler, resolves service context and a lazy registry inside the callback, and performs one bounded drain. Keep the Next-specific wrapper in server/providers.ts, after the database provider that installs uow:

    import { createObservedUnitOfWork } from "@beignet/core/ports";
    import { createProvider } from "@beignet/core/providers";
    import { createNextOutboxDrainTrigger } from "@beignet/next";
    import { after } from "next/server";
    import type { AppContext } from "@/app-context";
    import type { AppPorts } from "@/ports";
    import type { AppServiceContextInput } from "./context";

    const outboxDrainProvider = createProvider<
    Pick<AppPorts, "uow">,
    AppContext,
    AppServiceContextInput
    >()({
    name: "outbox-drain-trigger",
    setup({ ports, createServiceContext }): { ports: Pick<AppPorts, "uow"> } {
    const trigger: () => void = createNextOutboxDrainTrigger({
    defer: after,
    createContext: () => createServiceContext(undefined),
    registry: async () => (await import("./outbox")).outboxRegistry,
    batchSize: 100,
    });

    return {
    ports: {
    uow: createObservedUnitOfWork({
    unitOfWork: ports.uow,
    afterCommit: trigger,
    }),
    },
    };
    },
    });

    Register outboxDrainProvider after the provider that installs the database Unit of Work. Keeping the wrapper in server composition avoids an infra -> server dependency while the lazy registry import avoids the server/providers.ts and server/outbox.ts boot cycle.

    This is a low-latency optimization, not durable execution. Keep the cron route as a recovery sweep, typically around every 15 minutes. Delayed messages, future retry timestamps, missed callbacks, and batches larger than the trigger limit depend on that sweep. Use a durable jobs provider when retries need a guaranteed low-latency wake-up. Older Next.js versions continue to use the cron-only route. Repeated triggers coalesce while a drain is scheduled or running, including triggers from transactions started by outbox handlers.

    When ctx.ports.errorReporter is available, the route reports successfully dead-lettered messages, failed failure-settlement writes, and drain-level infrastructure failures. Scheduled retries remain in outbox instrumentation and do not create incident reports. Reporter failures do not change the drain result.

    Use createScheduleRoute to trigger one registered schedule from a cron or scheduled serverless route. The helper requires a bearer secret, builds app context from the real request with server.createRequestContext(...), runs the schedule with the inline runner from @beignet/core/schedules, and records schedule events through the instrumentation port resolved from ctx.ports (ports.instrumentation, then ports.devtools) with the request's correlation fields.

    // app/api/cron/digests/daily-digest/route.ts
    import { createScheduleRoute } from "@beignet/next";
    import { env } from "@/lib/env";
    import { getServer } from "@/server";
    import { schedules } from "@/server/schedules";

    export const runtime = "nodejs";

    export const { GET, POST } = createScheduleRoute({
    server: getServer,
    schedules,
    schedule: "digests.send-daily",
    secret: env.CRON_SECRET,
    source: "vercel-cron",
    });

    The schedule name is resolved when the route module loads, so unknown names throw at build or boot time instead of at the first cron invocation.

    Authentication matches createOutboxDrainRoute: the bearer secret is checked with a timing-safe comparison, and a missing secret fails closed with a 500 response. The check runs before server resolution and context creation. Successful runs return { ok: true, scheduleName }; failed runs log through ctx.ports.logger, report once through ctx.ports.errorReporter when present, and return a 500 so schedule providers can retry. Reporter failures do not change the response.

    source defaults to "next-cron-route" and is recorded on run metadata and devtools events.

    The webhook, payment webhook, schedule, and outbox drain route factories build app context from the incoming request, not from next/headers, so route modules execute under a standard test runner with a plain Request. Import the route module directly and call the exported handler:

    // app/api/webhooks/github/route.test.ts
    import assert from "node:assert/strict";
    import { it } from "node:test";
    import { POST } from "./route";

    it("rejects unsigned webhook deliveries", async () => {
    const response = await POST(
    new Request("http://localhost/api/webhooks/github", {
    method: "POST",
    body: JSON.stringify({ action: "opened" }),
    }),
    );

    assert.equal(response.status, 400);
    });

    To test a route factory against controlled context without booting the real server, pass any object exposing createRequestContext as the server option (NextRouteServer<Ctx>):

    import assert from "node:assert/strict";
    import { it } from "node:test";
    import { createScheduleRoute } from "@beignet/next";
    import { schedules } from "@/server/schedules";

    it("triggers the daily digest schedule", async () => {
    const { POST } = createScheduleRoute({
    schedules,
    schedule: "digests.send-daily",
    secret: "test-secret",
    server: {
    async createRequestContext() {
    return createTestScheduleContext();
    },
    },
    });

    const response = await POST(
    new Request("http://localhost/api/cron/digests/daily-digest", {
    method: "POST",
    headers: { authorization: "Bearer test-secret" },
    }),
    );

    assert.equal(response.status, 200);
    });

    Use createClient from @beignet/core/client for modules under client/ or Client Component import graphs. Browser calls default to same-origin relative URLs when no baseUrl is provided.

    // client/index.ts
    import { createClient } from "@beignet/core/client";

    export const apiClient = createClient({
    headers: async () => ({}),
    validateInput: true,
    });

    The client gets endpoint types from the contract passed to apiClient.endpoint(contract). Next-friendly defaults supply same-origin browser URLs when no baseUrl is provided.

    If server-side code must call HTTP instead of a use case or route handler directly, pass an absolute baseUrl to createClient(...) or use createNextClient outside client-root modules. createNextClient resolves server calls through NEXT_PUBLIC_API_URL, then VERCEL_URL, then http://localhost:${PORT || 3000}.

    export const apiClient = createNextClient({
    serverBaseUrl: () => `http://localhost:${process.env.PORT || 3002}`,
    });

    For deployed apps, prefer setting NEXT_PUBLIC_API_URL when API calls should target a different origin.

    Beignet supports the normal App Router split:

    • Server Components can call use cases directly with a cached server.createContextFromNext() helper.
    • Server Components can prefetch contract queries and hydrate Client Components with TanStack Query.
    • Client Components use createClient() plus React Query options for interactive server state.
    • Route handlers stay thin and use createApiRoute(getServer) or focused helpers such as OpenAPI, uploads, storage, devtools, and outbox drains.

    For repeated Server Component and layout access, define a cached app helper:

    // lib/server-context.ts
    import "@beignet/core/server-only";

    import { cache } from "react";
    import { getServer } from "@/server";

    export const getAppRequestContext = cache(async () => {
    const server = await getServer();
    return server.createContextFromNext();
    });

    Layouts and Server Components can use that context for request metadata, ctx.auth, and ctx.tenant without turning those reads into HTTP calls. Keep feature data and business workflows behind use cases.

    Use this when the page only needs server-rendered data and does not need a browser cache for the result:

    // app/posts/[slug]/page.tsx
    import { getAppRequestContext } from "@/lib/server-context";
    import { getPostUseCase } from "@/features/posts/use-cases/get-post";

    export const dynamic = "force-dynamic";

    type PageProps = {
    params: Promise<{
    slug: string;
    }>;
    };

    export default async function Page({ params }: PageProps) {
    const { slug } = await params;
    const ctx = await getAppRequestContext();
    const post = await getPostUseCase.run({
    ctx,
    input: { slug },
    });

    return <article>{post.title}</article>;
    }

    Use this when the first render should be server-prefetched, but a Client Component should keep using TanStack Query for refetching, mutations, invalidation, and optimistic updates. For thin { contract, useCase } routes, keep the same contract-derived query key and replace only the server query function with an in-process use-case call:

    // app/posts/[slug]/page.tsx
    import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
    import { makeQueryClient, rq } from "@/client";
    import { getPost } from "@/features/posts/contracts";
    import { PostDetail } from "@/features/posts/components/post-detail";
    import { getPostUseCase } from "@/features/posts/use-cases";
    import { getAppRequestContext } from "@/lib/server-context";
    import { serverUseCaseQueryOptions } from "@/lib/server-react-query";

    type PageProps = {
    params: Promise<{
    slug: string;
    }>;
    };

    export default async function Page({ params }: PageProps) {
    const { slug } = await params;
    const ctx = await getAppRequestContext();
    const queryClient = makeQueryClient();

    await queryClient.prefetchQuery(
    serverUseCaseQueryOptions(
    rq(getPost).queryOptions({ path: { slug } }),
    getPostUseCase,
    ctx,
    { slug },
    ),
    );

    return (
    <HydrationBoundary state={dehydrate(queryClient)}>
    <PostDetail slug={slug} />
    </HydrationBoundary>
    );
    }

    params and searchParams are promises in modern App Router pages and route handlers. Await them before building contract path or query params.

    Use this use-case path only when the route is a direct use-case binding and the use case output is the contract success body. If a route handler owns response mapping, headers, streaming, or other HTTP-layer behavior, prefetch through rq(contract).queryOptions(...) and use createNextClient(...) when server code needs an HTTP client with absolute base URL defaults.

    Providers are service adapters that implement ports (database, cache, logger, etc.):

    import { createNextServer, createNextServerLoader } from "@beignet/next";
    import { createDrizzleSqliteProvider } from "@beignet/provider-db-drizzle/sqlite";
    import { createPinoLoggerProvider } from "@beignet/provider-logger-pino";
    import * as schema from "@/infra/db/schema";

    const drizzleSqliteProvider = createDrizzleSqliteProvider({ schema });

    export const getServer = createNextServerLoader(() =>
    createNextServer({
    ports: {},
    providers: [
    drizzleSqliteProvider,
    createPinoLoggerProvider(),
    ],
    providerEnv: process.env,
    context: async ({ ports }) => ({
    // Access providers via ports
    db: ports.db,
    logger: ports.logger,
    }),
    mapUnhandledError: () => ({
    status: 500,
    body: {
    code: "INTERNAL_SERVER_ERROR",
    message: "Internal server error",
    },
    }),
    }),
    );
    import { createNextServer, createNextServerLoader } from "@beignet/next";

    export const getServer = createNextServerLoader(() =>
    createNextServer({
    ports: {},
    context: async () => ({}),
    mapUnhandledError: ({ err }) => {
    console.error("Unhandled error:", err);
    return {
    status: 500,
    body: {
    code: "INTERNAL_SERVER_ERROR",
    message: "Internal server error",
    },
    };
    },
    }),
    );

    mapUnhandledError response bodies are sent to clients. Use onCaughtError for diagnostics, logging, and error reporting; keep response details limited to stable public fields that your app intentionally exposes.

    Declare expected business failures on the contract with .errors(...), then throw your app's catalog helper from handlers or use cases.

    import { appError } from "@/features/shared/errors";

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

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

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

    Creates a @beignet/core/client instance with Next.js-friendly base URL defaults.

    Resolves the base URL used by createNextClient.

    Creates a Next.js route handler that returns an OpenAPI 3.1 JSON document. Requires @beignet/core/openapi in the app.

    Pass servers for deployed docs. If servers is omitted, no OpenAPI servers entry is generated unless inferServersFromRequest: true is set.

    When you use central route registration, export contractsFromRoutes(routes) from server/routes.ts so OpenAPI is generated from the same route list used by the runtime without booting providers during route-module import.

    Creates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint.

    Creates a generic Next.js webhook route. The route reads the raw body, builds app context from the real request with server.createRequestContext(...), verifies the event through a webhook definition or context-aware verifier, validates the matching event payload schema, and delegates the event to your app-owned handler. Verified event types outside the webhook catalog fail with a 400 unless allowUnknownEvents: true is set.

    Creates a Next.js payment webhook route. The route reads the raw body, builds app context from the real request with server.createRequestContext(...), verifies the provider signature through ctx.ports.payments.verifyWebhook(...), and delegates the normalized event to your app-owned handler. Prefer createWebhookRoute(...) for new non-payment webhook integrations.

    Operational helper error codes include UPLOAD_NOT_FOUND, INVALID_UPLOAD_ACTION, CRON_SECRET_NOT_CONFIGURED, UNAUTHORIZED, OUTBOX_DRAIN_FAILED, WEBHOOK_BODY_READ_FAILED, WEBHOOK_CONTEXT_FAILED, WEBHOOK_VERIFICATION_FAILED, WEBHOOK_HANDLER_FAILED, PAYMENT_WEBHOOK_SIGNATURE_MISSING, PAYMENT_WEBHOOK_BODY_READ_FAILED, PAYMENT_WEBHOOK_CONTEXT_FAILED, PAYMENT_WEBHOOK_VERIFICATION_FAILED, PAYMENT_WEBHOOK_HANDLER_FAILED, and SCHEDULE_FAILED.

    Web Fetch conversion helpers such as toRequestLike(...) and toWebResponse(...) belong to @beignet/web. The Next adapter uses them internally but does not re-export them.

    // features/todos/contracts.ts
    import { defineContractGroup } from "@beignet/core/contracts";
    import { z } from "zod";

    const todos = defineContractGroup()
    .namespace("todos")
    .prefix("/api/todos");

    const todoSchema = z.object({
    id: z.string(),
    title: z.string(),
    completed: z.boolean(),
    });

    export const listTodos = todos
    .get("/")
    .responses({ 200: z.array(todoSchema) });

    export const getTodo = todos
    .get("/:id")
    .pathParams(z.object({ id: z.string() }))
    .responses({ 200: todoSchema });

    export const createTodo = todos
    .post("/")
    .body(z.object({ title: z.string() }))
    .responses({ 201: todoSchema });

    export const updateTodo = todos
    .put("/:id")
    .pathParams(z.object({ id: z.string() }))
    .body(z.object({ title: z.string(), completed: z.boolean() }))
    .responses({ 200: todoSchema });

    export const deleteTodo = todos
    .delete("/:id")
    .pathParams(z.object({ id: z.string() }))
    .responses({ 204: null });
    // app-context.ts
    export type AppContext = {
    todos: Array<{ id: string; title: string; completed: boolean }>;
    };
    // lib/routes.ts
    import "@beignet/core/server-only";
    import { createRoutes } from "@beignet/core/server";
    import type { AppContext } from "@/app-context";

    export const { defineRoute, defineRouteGroup } = createRoutes<AppContext>();
    // features/todos/routes.ts
    import { defineRouteGroup } from "@/lib/routes";
    import * as todosContracts from "@/features/todos/contracts";

    export const todoRoutes = defineRouteGroup({
    name: "todos",
    routes: [
    { contract: todosContracts.listTodos, handle: async () => ({ status: 200, body: [] }) },
    { contract: todosContracts.getTodo, handle: async ({ path }) => ({ status: 200, body: { id: path.id, title: "...", completed: false } }) },
    { contract: todosContracts.createTodo, handle: async ({ body }) => ({ status: 201, body: { id: "1", ...body, completed: false } }) },
    { contract: todosContracts.updateTodo, handle: async ({ path, body }) => ({ status: 200, body: { id: path.id, ...body } }) },
    { contract: todosContracts.deleteTodo, handle: async () => ({ status: 204 }) },
    ],
    });
    // 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);
    // server/index.ts
    import { createNextServer, createNextServerLoader } from "@beignet/next";
    import type { AppContext } from "@/app-context";
    import { routes } from "@/server/routes";

    export const getServer = createNextServerLoader(() =>
    createNextServer<AppContext>({
    ports: {},
    routes,
    context: async () => ({ todos: [] }),
    mapUnhandledError: () => ({
    status: 500,
    body: {
    code: "INTERNAL_SERVER_ERROR",
    message: "Internal server error",
    },
    }),
    }),
    );
    // 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);
    // server/index.ts
    import { createNextServer, createNextServerLoader } from "@beignet/next";
    import { AuthUnauthorizedError } from "@beignet/core/ports";
    import { getTodo } from "@/features/todos/contracts";

    export const getServer = createNextServerLoader(() =>
    createNextServer({
    ports: {},
    context: async ({ req }) => {
    const user = await getUserFromRequest(req);

    if (!user) {
    throw new AuthUnauthorizedError();
    }

    return { user };
    },
    mapUnhandledError: () => {
    return {
    status: 500,
    body: {
    code: "INTERNAL_SERVER_ERROR",
    message: "Internal server error",
    },
    };
    },
    }),
    );

    You can call use cases directly from React Server Components using the cached request-context helper from lib/server-context.ts:

    // app/todos/page.tsx
    import { getAppRequestContext } from "@/lib/server-context";
    import { listTodosUseCase } from "@/features/todos/use-cases/list-todos";

    export const dynamic = "force-dynamic";

    export default async function TodosPage() {
    const ctx = await getAppRequestContext();

    const result = await listTodosUseCase.run({
    ctx,
    input: { limit: 10, offset: 0 },
    });

    return (
    <div>
    <h1>Todos</h1>
    <ul>
    {result.items.map((todo) => (
    <li key={todo.id}>{todo.title}</li>
    ))}
    </ul>
    </div>
    );
    }

    This approach:

    • Eliminates unnecessary API routes for server-side data fetching
    • Maintains type safety and business logic separation
    • Automatically handles headers and cookies from Next.js
    • Reuses the same cached request context across a Server Component render pass

    MIT

    NextServer
    CreateNextOutboxDrainTriggerOptions
    CreateOpenAPIHandlerOptions
    CreateOutboxDrainRouteOptions
    CreatePaymentWebhookRouteOptions
    CreateScheduleRouteOptions
    CreateStorageRouteOptions
    CreateSwaggerUIHandlerOptions
    CreateUploadRouteOptions
    CreateWebhookRouteOptions
    NextApiRouteHandlers
    NextClientConfig
    NextDeferredWorkScheduler
    NextRouteServer
    NextServerLoader
    OpenAPIServer
    PaymentWebhookRouteHandlerArgs
    PaymentWebhookRouteHandlerResult
    WebhookRouteHandlerArgs
    WebhookRouteHandlerResult
    WebhookRouteVerifyArgs
    createApiRoute
    createHealthRoute
    createNextClient
    createNextOutboxDrainTrigger
    createNextServer
    createNextServerLoader
    createOpenAPIHandler
    createOutboxDrainRoute
    createPaymentWebhookRoute
    createScheduleRoute
    createStorageRoute
    createSwaggerUIHandler
    createUploadRoute
    createWebhookRoute
    resolveNextBaseUrl