@beignet/next
v0.0.3
Published
Next.js server-side handlers for Beignet
Maintainers
Readme
@beignet/next
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, outbox drains, storage routes, and client
base URLs.
Installation
npm install @beignet/next nextOptional add-ons
@beignet/core/openapifor OpenAPI documentation@beignet/core/portsif you want to define shared ports explicitly in your app
TypeScript requirements
This package requires TypeScript 5.0 or higher for proper type inference.
Quick start
1. Define your contracts
// features/todos/contracts.ts
import { createContractGroup } from "@beignet/core/contracts";
import { z } from "zod";
const todos = createContractGroup()
.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(),
}) });2. Create your server
// server/index.ts
import {
createNextServer,
defineRouteGroup,
defineRoutes,
} from "@beignet/next";
import { getTodo } from "@/features/todos/contracts";
export const server = await createNextServer({
ports: {},
routes: defineRoutes([
{
contract: getTodo,
handle: async ({ path }) => ({
status: 200,
body: {
id: path.id,
title: "Example todo",
completed: false,
},
}),
},
]),
createContext: 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: ({ err }) => ({
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
...(err instanceof Error ? { details: { message: err.message } } : {}),
},
}),
});For larger apps, group related handlers near the feature and compose them with
defineRoutes:
const todoRoutes = defineRouteGroup({
name: "todos",
routes: [
{
contract: getTodo,
handle: async ({ path }) => ({
status: 200,
body: {
id: path.id,
title: "Example todo",
completed: false,
},
}),
},
],
});
export const routes = defineRoutes([todoRoutes]);3. Set up routes
You have two options for routing:
Option A: catch-all framework route (recommended)
Register handlers centrally in server/index.ts, then expose the central
handler from one catch-all Next.js route file:
// 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;Option B: per-contract routes
Create individual route files for each contract:
// app/api/todos/[id]/route.ts
import { server } from "@/server";
import { getTodo } from "@/features/todos/contracts";
export const GET = server
.route(getTodo)
.handle(async ({ ctx, path }) => {
// Implement your handler logic
const todo = await fetchTodoById(path.id);
return {
status: 200,
body: todo,
};
});Raw requests and non-JSON responses
@beignet/next exposes the underlying web Request through
HttpRequestLike. This lets handlers read raw bodies for webhooks while keeping
the normal JSON contract flow for the rest of the app.
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 } };
});For downloads, plain text, and redirects, return a native web Response:
export const GET = server.route(downloadFile).handle(async () =>
new Response(await loadFile(), {
headers: { "Content-Type": "application/octet-stream" },
}),
);
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 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.
API reference
createNextServer<Ctx>(options)
Creates a Next.js server instance with the given options.
Parameters:
options: Same ascreateServerfrom@beignet/core/server:ports: Required - Ports object defining available service interfacescreateContext: Async function to create request contextmapUnhandledError: 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 overrides
Returns: Promise<NextServer<Ctx>>
NextServer methods
server.api
A Next.js handler 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 { 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;server.route(contract)
Returns a route builder for creating custom handlers for a specific contract. The contract is registered globally and available via server.api.
Returns: Route builder with:
handle(fn): Create a custom handler function
// app/api/todos/[id]/route.ts
import { server } from "@/server";
import { getTodo } from "@/features/todos/contracts";
import { getTodoUseCase } from "@/features/todos/use-cases/get-todo";
// Option 1: Custom handler
export const GET = server
.route(getTodo)
.handle(async ({ req, ctx, path, query, body }) => {
// Your implementation
return { status: 200, body: { id: path.id, title: "..." } };
});
// Option 2: Call a use case inside the handler
export const GET = server
.route(getTodo)
.handle(async ({ ctx, path }) => {
const todo = await getTodoUseCase.run({
ctx,
input: { id: path.id },
});
return { status: 200, body: todo };
});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 context object from createContext
// app/my-page/page.tsx
import { server } from "@/server";
import { getTodoUseCase } from "@/features/todos/use-cases/get-todo";
export default async function MyPage() {
// Create context from Next.js headers and cookies
const ctx = await server.createContextFromNext();
// Call use case directly
const todo = await getTodoUseCase.run({
ctx,
input: { id: "123" }
});
return <div>{todo.title}</div>;
}This method:
- Automatically calls Next.js's
headers()andcookies()functions - Creates a minimal Request-like object with headers and cookies access
- Calls your
createContextfunction with this request object - Returns the same context type you get in API route handlers
- Uses the HTTP method
"GET"for the internal Request-like object. If yourcreateContextimplementation inspectsreq.method, it will always see"GET"when invoked viacreateContextFromNext(). - The
req.urlis set to a placeholder (http://core/server-component.invalid) since Server Components don't have real HTTP URLs - The
req.json()andreq.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).
server.stop()
Stops the server and cleans up resources (closes provider connections, etc.).
await server.stop();Handler function context
When using .handle(), your handler function receives an object with:
{
req: HttpRequestLike, // Raw request object
ctx: Ctx, // Your custom context from createContext
path: PathParams, // Validated path parameters
query: QueryParams, // Validated query parameters
body: Body, // Validated request body
contract: Contract, // Resolved contract metadata and schemas
}Use case integration
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
Hooks can be added at the server level:
import { createNextServer } from "@beignet/next";
import { createLoggingHooks } from "@beignet/core/server";
const logging = createLoggingHooks({
logger: console,
requestIdHeader: "x-request-id",
});
export const server = await createNextServer({
ports: {},
hooks: [logging],
createContext: async () => ({}),
mapUnhandledError: () => ({
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
},
}),
});OpenAPI documentation
If you have @beignet/core/openapi installed, use createOpenAPIHandler for a Next.js route. The handler infers the current request origin and adds it as the OpenAPI server by default.
// 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",
});server.contracts is populated from contracts registered through
createNextServer({ routes }). If you export per-file Next handlers with
server.route(contract).handle(...), keep an explicit contract list or exported
route registry for OpenAPI 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",
});Public storage routes
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 { server } from "@/server";
export const { GET, HEAD } = createStorageRoute(server.ports.storage, {
basePath: "/storage",
});Served responses preserve object Content-Type, Cache-Control,
Content-Length, and Last-Modified headers when available.
Upload routes
Use createUploadRoute to expose a Beignet upload router from a focused App
Router route:
// app/api/uploads/[uploadName]/[action]/route.ts
import { createUploadRouter } from "@beignet/core/uploads";
import { createUploadRoute } from "@beignet/next";
import { postUploads } from "@/features/posts/uploads";
import { server } from "@/server";
const uploadRouter = createUploadRouter({
uploads: postUploads,
ctx: () => server.createContextFromNext(),
storage: server.ports.storage,
instrumentation: server.ports.devtools,
});
export const { POST } = createUploadRoute(uploadRouter);The action segment must be prepare, upload, or complete.
Outbox drain routes
Use createOutboxDrainRoute to expose durable outbox delivery from a cron or
scheduled serverless route. The helper requires a bearer secret, drains one
bounded batch with @beignet/core/outbox, records a devtools event when
available, and returns a JSON summary.
// app/api/cron/outbox/drain/route.ts
import { createOutboxDrainRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { server } from "@/server";
import { outboxRegistry } from "@/server/outbox";
export const runtime = "nodejs";
export const { GET, POST } = createOutboxDrainRoute({
server,
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 <CRON_SECRET>Do not start long-running outbox polling loops from provider lifecycle hooks in serverless apps.
Client creation
Use createNextClient when a client may run in both browser and server environments. Browser calls default to same-origin relative URLs. Server calls use NEXT_PUBLIC_API_URL, then VERCEL_URL, then http://localhost:${PORT || 3000}.
// client/api-client.ts
import { createNextClient } from "@beignet/next";
export const apiClient = createNextClient({
headers: async () => ({}),
});If your local app runs on a non-default port, provide a server-only fallback:
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.
Providers
Providers are service adapters that implement ports (database, cache, logger, etc.):
import { createNextServer } from "@beignet/next";
import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";
import { loggerPinoProvider } from "@beignet/provider-logger-pino";
import * as schema from "@/db/schema";
const drizzleTursoProvider = createDrizzleTursoProvider({ schema });
export const server = await createNextServer({
ports: {},
providers: [
drizzleTursoProvider,
loggerPinoProvider,
],
providerEnv: process.env,
createContext: async ({ ports }) => ({
// Access providers via ports
db: ports.db,
logger: ports.logger,
}),
mapUnhandledError: () => ({
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
},
}),
});Error handling
Global error handler
export const server = await createNextServer({
ports: {},
createContext: async () => ({}),
mapUnhandledError: ({ err, ctx }) => {
console.error("Unhandled error:", err);
return {
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
...(process.env.NODE_ENV === "development" && err instanceof Error
? { error: err.message }
: {}),
},
};
},
});Route-level error handling
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 };
});Helper functions
createNextClient(config?): Client
Creates a @beignet/core/client instance with Next.js-friendly base URL defaults.
resolveNextBaseUrl(config?): string
Resolves 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.
When you use central route registration, prefer server.contracts or
contractsFromRoutes(routes) so OpenAPI is generated from the same route list
used by the runtime.
createSwaggerUIHandler(options?): (req: Request) => Response
Creates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint.
toRequestLike(req: Request): HttpRequestLike
Re-export from @beignet/web. Converts a standard Request to the
framework-agnostic HttpRequestLike shape.
toWebResponse(res: HttpResponseLike): Response
Re-export from @beignet/web. Converts an HttpResponseLike to a standard
Response.
toNextResponse(res: HttpResponseLike): Response
Next-named alias for toWebResponse(...).
These are used internally by the adapter but can be used directly if needed.
Examples
Basic CRUD API
// features/todos/contracts.ts
import { createContractGroup } from "@beignet/core/contracts";
import { z } from "zod";
const todos = createContractGroup()
.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 });
// server/index.ts
import { createNextServer, defineRoutes } from "@beignet/next";
import * as todosContracts from "@/features/todos/contracts";
export const server = await createNextServer({
ports: {},
routes: defineRoutes([
{ 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 }) },
]),
createContext: async () => ({ todos: [] }),
mapUnhandledError: () => ({
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
},
}),
});// 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;With authentication
// server/index.ts
import { createNextServer } from "@beignet/next";
import { AuthUnauthorizedError } from "@beignet/core/ports";
import { getTodo } from "@/features/todos/contracts";
export const server = await createNextServer({
ports: {},
createContext: 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",
},
};
},
});Server component usage
You can call use cases directly from React Server Components using createContextFromNext():
// app/todos/page.tsx
import { server } from "@/server";
import { listTodosUseCase } from "@/features/todos/use-cases/list-todos";
export default async function TodosPage() {
// Create context from Next.js runtime
const ctx = await server.createContextFromNext();
// Call use case directly - no API route needed!
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 your existing context creation logic
Related packages
- @beignet/core/server - Framework-agnostic server runtime
- @beignet/core/contracts - Contract definitions
- @beignet/core/openapi - OpenAPI spec generation
- @beignet/core/client - Type-safe HTTP client
License
MIT
