Skip to main content

Overview

@contract-kit/next is the Next.js adapter for the framework-agnostic @contract-kit/server runtime. It translates Next.js Request/Response to/from the runtime’s internal types and provides a minimal API for both catch-all and per-contract routing.

Installation

npm install @contract-kit/next @contract-kit/server @contract-kit/core zod

Peer Dependencies

  • next: ^14.0.0 || ^15.0.0 || ^16.0.0
  • @contract-kit/openapi: ^0.1.0 (optional, for OpenAPI documentation)

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)
    // In real apps, derive auth from a trusted mechanism (session, JWT, NextAuth, etc.),
    // and do not rely on raw headers for user identity.
    return {
      userId: "anonymous", // Use proper auth instead of raw headers
    };
  },
  onUnhandledError: (error) => ({
    status: 500,
    body: { message: "Internal server error" },
  }),
});

3. Set Up Routes

You have two options for routing: 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 as createServer from @contract-kit/server:
    • ports: Required - Ports object defining available service interfaces
    • createContext: Async function to create request context
    • onUnhandledError: Error handler function
    • routes?: Array of route configurations (contract + handler)
    • middleware?: Optional array of middleware
    • providers?: Optional array of service providers
    • providerEnv?: Optional environment variables for providers
    • providerConfig?: 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 function
  • useCase(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/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 headers and cookies
  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>
  );
}
Key Benefits:
  • 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
Important Limitations:
  • Can only be called from Next.js Server Components (not in Client Components or during build time)
  • The internal Request-like object uses HTTP method "GET". If your createContext checks req.method, it will always see "GET".
  • The req.url is set to a placeholder (http://server-component.invalid) since Server Components don’t have real HTTP URLs
  • The req.json() and req.text() methods return empty values since there’s no actual HTTP request body in Server Components

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" } }),
});

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: unknown) {
      if (
        typeof error === "object" &&
        error !== null &&
        "code" in error &&
        (error as { code?: unknown }).code === "NOT_FOUND"
      ) {
        return { status: 404, body: { message: "Todo not found" } };
      }
      throw error; // Will be caught by global error handler
    }
  });

OpenAPI Documentation

If you have @contract-kit/openapi installed, you can generate OpenAPI documentation:
// app/api/openapi/route.ts
import { contractsToOpenAPI } from "@contract-kit/openapi";
import { getTodo } from "@/contracts/todo";

export async function GET() {
  const spec = contractsToOpenAPI([getTodo], {
    title: "My API",
    version: "1.0.0",
  });

  return Response.json(spec);
}

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" }
    };
  },
});

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.

Best Practices

The catch-all route pattern is recommended for most applications as it reduces boilerplate.
Keep business logic in use cases and use .useCase() to connect them to routes.
Only include data in context that’s needed across multiple routes. Keep it lightweight.
Use the global error handler for common errors and route-level handling for specific cases.

Next Steps