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 appThis 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.
createNextServer<Ctx>(options)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 interfacescontext: 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 functionroutes?: Array of route configurations (contract + handler)hooks?: Optional ordered server hooksproviders?: Optional array of service providersproviderEnv?: Optional environment variables for providersproviderConfig?: Optional provider configuration overridesReturns: Promise<NextServer<Ctx>>
createApiRoute(getServer)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.
server.route(contract)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);
}
server.rawRoute(init)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.
server.createContextFromNext()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:
headers() and cookies() functionsheaders() 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.server.createRequestContext(req) so the request context
factory and gate attachment run exactly like API route handlers"GET" for the internal Request-like object. If your request context factory inspects req.method, it will always see "GET" when invoked via createContextFromNext().req.url is set to a placeholder (http://core/server-component.invalid) since Server Components don't have real HTTP URLsreq.json() and req.text() methods return empty values since there's no actual HTTP request body in Server ComponentsNote: This method can only be called from Next.js Server Components (not in Client Components or during build time).
server.createRequestContext(req)Builds a fully assembled request context from a framework-neutral
HttpRequestLike. Use it for adapter entry points outside the route pipeline.
server.createServiceContext(input?)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.
server.stop()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.createContextFromNext() helper.createClient() plus React Query options for interactive server state.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 };
});
createNextClient(config?): ClientCreates a @beignet/core/client instance with Next.js-friendly base URL defaults.
resolveNextBaseUrl(config?): stringResolves the base URL used by createNextClient.
createOpenAPIHandler(contracts, options): (req: Request) => Promise<Response>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.
createSwaggerUIHandler(options?): (req: Request) => ResponseCreates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint.
createWebhookRoute(options): { POST }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.
createPaymentWebhookRoute(options): { POST }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:
MIT