Next.js adapter for the framework-agnostic @beignet/core/server runtime. It translates Next's Request/Response to/from the runtime's HttpRequestLike/HttpResponseLike shapes and provides small helpers for App Router handlers, OpenAPI routes, Swagger UI, and client base URLs.
npm install @beignet/next 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.
// 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(),
}) });
// 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]);
You have two options for routing:
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;
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,
};
});
@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.
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 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 overridesReturns: Promise<NextServer<Ctx>>
server.apiA 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:
headers() and cookies() functionscreateContext function with this request object"GET" for the internal Request-like object. If your createContext implementation 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.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 createContext
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 } 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",
},
}),
});
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",
});
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.
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.
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 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",
},
}),
});
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 }
: {}),
},
};
},
});
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.
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) => ResponseCreates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint.
toRequestLike(req: Request): HttpRequestLikeConverts a Next.js Request to the framework-agnostic HttpRequestLike shape.
toNextResponse(res: HttpResponseLike): ResponseConverts an HttpResponseLike to a Next.js Response.
These are used internally by the adapter but can be used directly if needed.
// 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;
// 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",
},
};
},
});
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:
MIT