@contract-kit/next
v0.1.2
Published
Next.js server-side handlers for contract-kit
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 exposes a minimal API for catch-all or per-contract routing.
Installation
npm install @contract-kit/next @contract-kit/server @contract-kit/corePeer Dependencies
next: ^14.0.0 || ^15.0.0 || ^16.0.0@contract-kit/openapi: ^0.1.0 (optional, for OpenAPI documentation)
TypeScript Requirements
This package requires TypeScript 5.0 or higher for proper type inference.
Quick Start
1. Define Your Contracts
// app/contracts/todo.ts
import { createContractGroup } from "@contract-kit/core";
import { z } from "zod";
const todos = createContractGroup();
export const getTodo = todos
.get("/todos/:id")
.path(z.object({ id: z.string() }))
.response(200, z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
}));2. Create Your Server
// app/lib/server.ts
import { createNextServer } from "@contract-kit/next";
import { getTodo } from "../contracts/todo";
export const server = await createNextServer({
ports: {},
routes: [
{
contract: getTodo,
handle: async ({ path }) => ({
status: 200,
body: {
id: path.id,
title: "Example todo",
completed: false,
},
}),
},
],
createContext: async ({ req }) => {
// Create request context (e.g., auth, database)
return {
userId: req.headers.get("x-user-id") || "anonymous",
};
},
onUnhandledError: (error) => ({
status: 500,
body: { message: "Internal server error" },
}),
});3. Set Up Routes
You have two options for routing:
Option A: Catch-All Route (Recommended)
Create a catch-all route that handles all contracts:
// app/api/[...contract-kit]/route.ts
import { server } from "@/lib/server";
export const GET = server.api();
export const POST = server.api();
export const PUT = server.api();
export const PATCH = server.api();
export const DELETE = server.api();Option B: Per-Contract Routes
Create individual route files for each contract:
// app/api/todos/[id]/route.ts
import { server } from "@/lib/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,
};
});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 contextonUnhandledError: Error handler functionroutes?: Array of route configurations (contract + handler)middleware?: Optional array of middlewareproviders?: Optional array of service providersproviderEnv?: Optional environment variables for providersproviderConfig?: Optional provider configuration overrides
Returns: Promise<NextServer<Ctx>>
NextServer Methods
server.api()
Returns a Next.js handler for catch-all routes. Use this with app/api/[...contract-kit]/route.ts to handle all registered contracts.
// app/api/[...contract-kit]/route.ts
import { server } from "@/lib/server";
export const GET = server.api();
export const POST = server.api();server.handle(contract)
Returns a Next.js handler for a specific contract. Use this when you want to handle a contract without registering it globally.
// app/api/todos/route.ts
import { server } from "@/lib/server";
import { getTodo } from "@/contracts/todo";
export const GET = server.handle(getTodo);server.route(contract)
Returns a route builder for creating custom handlers for a specific contract. The contract is NOT registered globally (won't be available via server.api()).
Returns: Route builder with:
handle(fn): Create a custom handler functionuseCase(useCase, maps): Connect a use case with input/output mapping
// app/api/todos/[id]/route.ts
import { server } from "@/lib/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 ({ ctx, path, query, body, headers }) => {
// Your implementation
return { status: 200, body: { id: path.id, title: "..." } };
});
// Option 2: Use case with mapping
export const GET = server
.route(getTodo)
.useCase(getTodoUseCase, {
mapInput: ({ path }) => ({ id: path.id }),
mapOutput: (result) => result,
});server.register(contract)
Returns a route builder (same as route()) but also registers the contract globally, making it available via server.api().
// app/lib/register-routes.ts
import { server } from "./server";
import { getTodo } from "../contracts/todo";
// Register and export handler
export const getTodoHandler = server
.register(getTodo)
.handle(async ({ ctx, path }) => {
// Now this contract is also available via server.api()
return { status: 200, body: { id: path.id, title: "..." } };
});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 "@/lib/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:
{
ctx: Ctx, // Your custom context from createContext
path: PathParams, // Validated path parameters
query: QueryParams, // Validated query parameters
body: Body, // Validated request body
headers: Headers, // Request headers (Web API Headers object)
}Use Case Integration
Contract Kit promotes clean architecture by separating use cases from HTTP concerns. Use the .useCase() method to connect use cases:
// 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)
.useCase(getTodoUseCase, {
mapInput: ({ path }) => ({ id: path.id }),
mapOutput: (result) => result,
});Middleware
Middleware can be added at the server level:
import { createNextServer } from "@contract-kit/next";
import { errorMiddleware, loggingMiddleware } from "@contract-kit/server/middleware";
export const server = await createNextServer({
ports: {},
middleware: [
loggingMiddleware({ logger: console }),
errorMiddleware(),
],
createContext: async () => ({}),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});OpenAPI Documentation
If you have @contract-kit/openapi installed, you can generate OpenAPI documentation:
// app/api/openapi/route.ts
import { generateOpenApiSpec } from "@contract-kit/openapi";
import { getTodo } from "@/contracts/todo";
export async function GET() {
const spec = generateOpenApiSpec({
title: "My API",
version: "1.0.0",
contracts: [getTodo],
});
return Response.json(spec);
}Providers
Providers are service adapters that implement ports (database, cache, logger, etc.):
import { createNextServer } from "@contract-kit/next";
import { drizzleTursoProvider } from "@contract-kit/provider-drizzle-turso";
import { pinoLoggerProvider } from "@contract-kit/provider-logger-pino";
export const server = await createNextServer({
ports: {},
providers: [
drizzleTursoProvider,
pinoLoggerProvider,
],
providerEnv: process.env,
createContext: async ({ ports }) => ({
// Access providers via ports
db: ports.db,
logger: ports.logger,
}),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});Error Handling
Global Error Handler
export const server = await createNextServer({
ports: {},
createContext: async () => ({}),
onUnhandledError: (error, { ctx }) => {
console.error("Unhandled error:", error);
return {
status: 500,
body: {
message: "Internal server error",
...(process.env.NODE_ENV === "development" && {
error: error.message
}),
},
};
},
});Route-Level Error Handling
export const GET = server
.route(getTodo)
.handle(async ({ ctx, path }) => {
try {
const todo = await fetchTodoById(path.id);
return { status: 200, body: todo };
} catch (error) {
if (error.code === "NOT_FOUND") {
return { status: 404, body: { message: "Todo not found" } };
}
throw error; // Will be caught by global error handler
}
});Helper Functions
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")
.response(200, z.array(todoSchema));
export const getTodo = todos
.get("/todos/:id")
.path(z.object({ id: z.string() }))
.response(200, todoSchema);
export const createTodo = todos
.post("/todos")
.body(z.object({ title: z.string() }))
.response(201, todoSchema);
export const updateTodo = todos
.put("/todos/:id")
.path(z.object({ id: z.string() }))
.body(z.object({ title: z.string(), completed: z.boolean() }))
.response(200, todoSchema);
export const deleteTodo = todos
.delete("/todos/:id")
.path(z.object({ id: z.string() }))
.response(204, z.void());
// app/api/[...contract-kit]/route.ts
import { createNextServer } from "@contract-kit/next";
import * as todosContracts from "@/contracts/todos";
const server = await createNextServer({
ports: {},
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 }) },
],
createContext: async () => ({ todos: [] }),
onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
export const GET = server.api();
export const POST = server.api();
export const PUT = server.api();
export const DELETE = server.api();With Authentication
// app/lib/server.ts
import { createNextServer } from "@contract-kit/next";
import { getTodo } from "@/contracts/todos";
export const server = await createNextServer({
ports: {},
createContext: async ({ req }) => {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
const user = await validateToken(token);
if (!user) {
throw new Error("Unauthorized");
}
return { user };
},
onUnhandledError: (error) => {
if (error.message === "Unauthorized") {
return {
status: 401,
body: { message: "Unauthorized" }
};
}
return {
status: 500,
body: { message: "Internal error" }
};
},
});Server Component Usage
You can call use cases directly from React Server Components using createContextFromNext():
// app/todos/page.tsx
import { server } from "@/lib/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
