@contract-kit/next
v1.0.0
Published
Next.js server-side handlers for contract-kit
Downloads
705
Maintainers
Readme
@contract-kit/next
Next.js adapter for the framework-agnostic @contract-kit/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.
Installation
npm install @contract-kit/next nextOptional add-ons
@contract-kit/openapifor OpenAPI documentation@contract-kit/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
// contracts/todo.ts
import { createContractGroup } from "@contract-kit/core";
import { z } from "zod";
const todos = createContractGroup();
export const getTodo = todos
.get("/todos/: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, defineRoutes } from "@contract-kit/next";
import { getTodo } from "@/contracts/todo";
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 } } : {}),
},
}),
});3. Set up routes
You have two options for routing:
Option A: framework route files (recommended)
Register handlers centrally in server/index.ts, then expose the central
handler from each matching Next.js route file:
// app/api/todos/[id]/route.ts
import { server } from "@/server";
export const GET = 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 "@/contracts/todo";
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
@contract-kit/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 Contract Kit to
validate a JSON response; use Response when you want full transport control.
Response-shaping hooks such as beforeSend only run for plain Contract Kit
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@contract-kit/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 route files. Use this in the matching app/api/**/route.ts
files for contracts registered in server/index.ts.
// app/api/todos/[id]/route.ts
import { server } from "@/server";
export const GET = 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 "@/contracts/todo";
import { getTodoUseCase } from "@/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 "@/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://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
Contract Kit promotes clean architecture by separating use cases from HTTP concerns. Call use cases from handlers so the HTTP layer stays explicit:
// 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 "@contract-kit/next";
import { createLoggingHooks } from "@contract-kit/server/hooks";
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 @contract-kit/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 "@contract-kit/next";
import { allContracts } from "@/contracts";
export const GET = createOpenAPIHandler(allContracts, {
title: "My API",
version: "1.0.0",
});You can also serve Swagger UI without writing the HTML route by hand:
// app/api/docs/route.ts
import { createSwaggerUIHandler } from "@contract-kit/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 "@contract-kit/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.
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 "@contract-kit/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 "@contract-kit/next";
import { createDrizzleTursoProvider } from "@contract-kit/provider-drizzle-turso";
import { loggerPinoProvider } from "@contract-kit/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 "@/server/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 @contract-kit/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 @contract-kit/openapi in the app.
createSwaggerUIHandler(options?): (req: Request) => Response
Creates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint.
toRequestLike(req: Request): HttpRequestLike
Converts a Next.js Request to the framework-agnostic HttpRequestLike shape.
toNextResponse(res: HttpResponseLike): Response
Converts an HttpResponseLike to a Next.js Response.
These are used internally by the adapter but can be used directly if needed.
Examples
Basic CRUD API
// contracts/todos.ts
import { createContractGroup } from "@contract-kit/core";
import { z } from "zod";
const todos = createContractGroup();
const todoSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
});
export const listTodos = todos
.get("/todos")
.responses({ 200: z.array(todoSchema) });
export const getTodo = todos
.get("/todos/:id")
.pathParams(z.object({ id: z.string() }))
.responses({ 200: todoSchema });
export const createTodo = todos
.post("/todos")
.body(z.object({ title: z.string() }))
.responses({ 201: todoSchema });
export const updateTodo = todos
.put("/todos/:id")
.pathParams(z.object({ id: z.string() }))
.body(z.object({ title: z.string(), completed: z.boolean() }))
.responses({ 200: todoSchema });
export const deleteTodo = todos
.delete("/todos/:id")
.pathParams(z.object({ id: z.string() }))
.responses({ 204: null });
// server/index.ts
import { createNextServer, defineRoutes } from "@contract-kit/next";
import * as todosContracts from "@/contracts/todos";
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/todos/route.ts
import { server } from "@/server";
export const GET = server.api;
export const POST = server.api;// app/api/todos/[id]/route.ts
import { server } from "@/server";
export const GET = server.api;
export const PATCH = server.api;
export const DELETE = server.api;With authentication
// server/index.ts
import { createNextServer } from "@contract-kit/next";
import { AuthUnauthorizedError } from "@contract-kit/ports";
import { getTodo } from "@/contracts/todos";
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 "@/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.todos.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
- @contract-kit/server - Framework-agnostic server runtime
- @contract-kit/core - Contract definitions
- @contract-kit/openapi - OpenAPI spec generation
- @contract-kit/client - Type-safe HTTP client
License
MIT
