Skip to main content

Overview

This guide walks you through setting up Contract Kit in a Next.js application from scratch. You’ll learn how to structure your project, configure the server, set up routing patterns, and integrate with the Next.js App Router.

Prerequisites

  • Node.js 18+ or Bun
  • Basic knowledge of TypeScript and Next.js
  • Familiarity with the App Router (recommended)

Installation

Create a new Next.js project if you haven’t already:
npx create-next-app@latest my-app
cd my-app
Install Contract Kit and its dependencies:
npm install contract-kit zod
npm install @contract-kit/next

Project Structure

A typical Contract Kit + Next.js project follows this structure:
my-app/
├── app/
│   ├── api/                  # API routes
│   │   ├── todos/            # Per-route handlers (Option B)
│   │   │   ├── [id]/
│   │   │   │   └── route.ts
│   │   │   └── route.ts
│   │   └── [...contract-kit]/  # Catch-all handler (Option A)
│   │       └── route.ts
│   ├── layout.tsx
│   └── page.tsx
├── contracts/                # API contract definitions
│   ├── todos/
│   │   └── index.ts
│   └── index.ts
├── domain/                   # Domain types (optional)
│   └── todo.ts
├── lib/
│   ├── api-client.ts        # Typed client for frontend
│   ├── ports.ts             # Port definitions
│   └── server.ts            # Server configuration
├── providers/                # Port implementations
│   └── db-memory.ts
├── use-cases/                # Business logic (optional)
│   └── todos/
│       └── get-todo.ts
└── package.json
This structure follows clean architecture principles with clear separation of concerns.

Step 1: Define Your Contracts

Contracts are the source of truth for your API. They define endpoints, validation rules, and response types. Create your first contract:
// contracts/todos/index.ts
import { createContractGroup } from "contract-kit";
import { z } from "zod";

const todos = createContractGroup();

// Define schemas
export const todoSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1).max(200),
  description: z.string().optional(),
  completed: z.boolean(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

// List todos with pagination and filtering
export const listTodos = todos
  .get("/api/todos")
  .query(z.object({
    limit: z.coerce.number().min(1).max(100).optional().default(10),
    offset: z.coerce.number().min(0).optional().default(0),
    completed: z.enum(["true", "false"])
      .transform((val) => val === "true")
      .optional(),
  }))
  .response(200, z.object({
    todos: z.array(todoSchema),
    total: z.number(),
    limit: z.number(),
    offset: z.number(),
  }));

// Get a single todo
export const getTodo = todos
  .get("/api/todos/:id")
  .path(z.object({ id: z.string().uuid() }))
  .response(200, todoSchema)
  .response(404, z.object({ message: z.string() }));

// Create a new todo
export const createTodo = todos
  .post("/api/todos")
  .body(z.object({
    title: z.string().min(1).max(200),
    description: z.string().optional(),
  }))
  .response(201, todoSchema)
  .response(400, z.object({ 
    message: z.string(), 
    errors: z.array(z.string()).optional() 
  }));

// Update an existing todo
export const updateTodo = todos
  .put("/api/todos/:id")
  .path(z.object({ id: z.string().uuid() }))
  .body(z.object({
    title: z.string().min(1).max(200).optional(),
    description: z.string().optional(),
    completed: z.boolean().optional(),
  }))
  .response(200, todoSchema)
  .response(404, z.object({ message: z.string() }))
  .response(400, z.object({ 
    message: z.string(), 
    errors: z.array(z.string()).optional() 
  }));

// Delete a todo
export const deleteTodo = todos
  .delete("/api/todos/:id")
  .path(z.object({ id: z.string().uuid() }))
  .response(204, z.void())
  .response(404, z.object({ message: z.string() }));
Export all contracts from a barrel file for easy importing:
// contracts/index.ts
export * from "./todos";

Step 2: Define Ports (Dependency Injection)

Ports define the interfaces for your application’s dependencies. They enable dependency injection and make your code testable.
// lib/ports.ts
import { definePorts } from "contract-kit";

export interface Todo {
  id: string;
  title: string;
  description?: string;
  completed: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export const ports = definePorts({
  db: {
    todos: {
      findAll: async (options?: { 
        limit?: number; 
        offset?: number; 
        completed?: boolean 
      }): Promise<{ todos: Todo[]; total: number }> => {
        throw new Error("Not implemented");
      },
      findById: async (id: string): Promise<Todo | null> => {
        throw new Error("Not implemented");
      },
      create: async (data: { 
        title: string; 
        description?: string 
      }): Promise<Todo> => {
        throw new Error("Not implemented");
      },
      update: async (id: string, data: { 
        title?: string; 
        description?: string; 
        completed?: boolean 
      }): Promise<Todo | null> => {
        throw new Error("Not implemented");
      },
      delete: async (id: string): Promise<boolean> => {
        throw new Error("Not implemented");
      },
    },
  },
});

export type AppPorts = typeof ports;

Step 3: Implement Providers

Providers are concrete implementations of your ports. Start with an in-memory provider for development:
// providers/db-memory.ts
import type { Todo } from "@/lib/ports";

const todos = new Map<string, Todo>();

// Helper function to serialize Todo with Date fields to ISO strings
function serializeTodo(todo: Todo) {
  return {
    ...todo,
    createdAt: todo.createdAt.toISOString(),
    updatedAt: todo.updatedAt.toISOString(),
  };
}

export const dbMemoryProvider = {
  todos: {
    findAll: async (options?: { 
      limit?: number; 
      offset?: number; 
      completed?: boolean 
    }) => {
      let allTodos = Array.from(todos.values());
      
      // Filter by completed status if provided
      if (options?.completed !== undefined) {
        allTodos = allTodos.filter(
          (todo) => todo.completed === options.completed
        );
      }
      
      const total = allTodos.length;
      const offset = options?.offset ?? 0;
      const limit = options?.limit ?? 10;
      
      // Apply pagination
      const paginatedTodos = allTodos.slice(offset, offset + limit);
      
      return { todos: paginatedTodos, total };
    },
    
    findById: async (id: string) => {
      return todos.get(id) ?? null;
    },
    
    create: async (data: { title: string; description?: string }) => {
      const now = new Date();
      const todo: Todo = {
        id: crypto.randomUUID(),
        title: data.title,
        description: data.description,
        completed: false,
        createdAt: now,
        updatedAt: now,
      };
      todos.set(todo.id, todo);
      return todo;
    },
    
    update: async (
      id: string, 
      data: { title?: string; description?: string; completed?: boolean }
    ) => {
      const todo = todos.get(id);
      if (!todo) return null;
      
      const updated: Todo = {
        ...todo,
        ...data,
        updatedAt: new Date(),
      };
      todos.set(id, updated);
      return updated;
    },
    
    delete: async (id: string) => {
      return todos.delete(id);
    },
  },
};
For production, replace this with a real database provider like @contract-kit/provider-drizzle-turso.

Step 4: Create the Server

Configure your Contract Kit server with context, error handling, and providers:
// lib/server.ts
import { createNextServer, defineRoutes } from "@contract-kit/next";
import { dbMemoryProvider } from "@/providers/db-memory";
import { type AppPorts, ports } from "./ports";
import * as todoContracts from "@/contracts/todos";

// Define your context type
type AppContext = {
  requestId: string;
  ports: AppPorts;
  userId?: string;
};

// Create the server instance
export const server = await createNextServer<AppContext>({
  ports,
  providers: [dbMemoryProvider],
  
  // Create request context
  createContext: async ({ req, ports }) => {
    // In production, extract user from session/JWT
    const userId = req.headers.get("x-user-id") || undefined;
    
    return {
      requestId: crypto.randomUUID(),
      ports,
      userId,
    };
  },
  
  // Register routes (for catch-all routing)
  routes: defineRoutes<AppContext>([
    {
      contract: todoContracts.listTodos,
      handle: async ({ ctx, query }) => {
        const result = await ctx.ports.db.todos.findAll({
          limit: query.limit,
          offset: query.offset,
          completed: query.completed,
        });
        // Transform Date objects to ISO strings for the response
        const serializedTodos = result.todos.map((todo) => ({
          ...todo,
          createdAt: todo.createdAt.toISOString(),
          updatedAt: todo.updatedAt.toISOString(),
        }));
        return { 
          status: 200, 
          body: { 
            todos: serializedTodos,
            total: result.total,
            limit: query.limit, 
            offset: query.offset 
          } 
        };
      },
    },
    {
      contract: todoContracts.getTodo,
      handle: async ({ ctx, path }) => {
        const todo = await ctx.ports.db.todos.findById(path.id);
        if (!todo) {
          return { 
            status: 404, 
            body: { message: "Todo not found" } 
          };
        }
        // Transform Date objects to ISO strings for the response
        return { 
          status: 200, 
          body: {
            ...todo,
            createdAt: todo.createdAt.toISOString(),
            updatedAt: todo.updatedAt.toISOString(),
          }
        };
      },
    },
    {
      contract: todoContracts.createTodo,
      handle: async ({ ctx, body }) => {
        const todo = await ctx.ports.db.todos.create(body);
        // Transform Date objects to ISO strings for the response
        return { 
          status: 201, 
          body: {
            ...todo,
            createdAt: todo.createdAt.toISOString(),
            updatedAt: todo.updatedAt.toISOString(),
          }
        };
      },
    },
    {
      contract: todoContracts.updateTodo,
      handle: async ({ ctx, path, body }) => {
        const todo = await ctx.ports.db.todos.update(path.id, body);
        if (!todo) {
          return { 
            status: 404, 
            body: { message: "Todo not found" } 
          };
        }
        // Transform Date objects to ISO strings for the response
        return { 
          status: 200, 
          body: {
            ...todo,
            createdAt: todo.createdAt.toISOString(),
            updatedAt: todo.updatedAt.toISOString(),
          }
        };
      },
    },
    {
      contract: todoContracts.deleteTodo,
      handle: async ({ ctx, path }) => {
        const deleted = await ctx.ports.db.todos.delete(path.id);
        if (!deleted) {
          return { 
            status: 404, 
            body: { message: "Todo not found" } 
          };
        }
        return { status: 204 };
      },
    },
  ]),
  
  // Global error handler
  onUnhandledError: (error, { ctx }) => {
    console.error("Unhandled error:", error);
    
    // Handle specific error types
    if (error instanceof Error && error.message.includes("not found")) {
      return {
        status: 404,
        body: { message: error.message },
      };
    }
    
    return {
      status: 500,
      body: {
        message: "Internal server error",
        requestId: ctx?.requestId,
      },
    };
  },
});

Step 5: Set Up API Routes

Contract Kit supports two routing patterns. Choose the one that fits your needs. This pattern uses a single catch-all route to handle all contracts registered in lib/server.ts.
// 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();
Advantages:
  • Minimal boilerplate
  • Centralized route configuration in lib/server.ts
  • Easy to add new endpoints
Best for: Most applications, especially those with many endpoints

Option B: Per-Route Files

This pattern creates individual route files for each endpoint. Routes are NOT registered in lib/server.ts.
// app/api/todos/route.ts
import { server } from "@/lib/server";
import { listTodos, createTodo } from "@/contracts/todos";

export const GET = server.route(listTodos).handle(async ({ ctx, query }) => {
  const result = await ctx.ports.db.todos.findAll({
    limit: query.limit,
    offset: query.offset,
    completed: query.completed,
  });
  // Transform Date objects to ISO strings for the response
  const serializedTodos = result.todos.map((todo) => ({
    ...todo,
    createdAt: todo.createdAt.toISOString(),
    updatedAt: todo.updatedAt.toISOString(),
  }));
  return { 
    status: 200, 
    body: { 
      todos: serializedTodos,
      total: result.total,
      limit: query.limit, 
      offset: query.offset 
    } 
  };
});

export const POST = server.route(createTodo).handle(async ({ ctx, body }) => {
  const todo = await ctx.ports.db.todos.create(body);
  // Transform Date objects to ISO strings for the response
  return { 
    status: 201, 
    body: {
      ...todo,
      createdAt: todo.createdAt.toISOString(),
      updatedAt: todo.updatedAt.toISOString(),
    }
  };
});
// app/api/todos/[id]/route.ts
import { server } from "@/lib/server";
import { getTodo, updateTodo, deleteTodo } from "@/contracts/todos";

export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
  const todo = await ctx.ports.db.todos.findById(path.id);
  if (!todo) {
    return { status: 404, body: { message: "Todo not found" } };
  }
  // Transform Date objects to ISO strings for the response
  return { 
    status: 200, 
    body: {
      ...todo,
      createdAt: todo.createdAt.toISOString(),
      updatedAt: todo.updatedAt.toISOString(),
    }
  };
});

export const PUT = server.route(updateTodo).handle(async ({ ctx, path, body }) => {
  const todo = await ctx.ports.db.todos.update(path.id, body);
  if (!todo) {
    return { status: 404, body: { message: "Todo not found" } };
  }
  // Transform Date objects to ISO strings for the response
  return { 
    status: 200, 
    body: {
      ...todo,
      createdAt: todo.createdAt.toISOString(),
      updatedAt: todo.updatedAt.toISOString(),
    }
  };
});

export const DELETE = server.route(deleteTodo).handle(async ({ ctx, path }) => {
  const deleted = await ctx.ports.db.todos.delete(path.id);
  if (!deleted) {
    return { status: 404, body: { message: "Todo not found" } };
  }
  return { status: 204 };
});
Advantages:
  • Clear file structure matching URL paths
  • Easier to locate specific endpoints
  • Good for code splitting
Best for: Applications with complex route-specific logic or large teams

Step 6: Create a Typed Client

Generate a fully typed client for your frontend:
// lib/api-client.ts
import { createClient } from "contract-kit";
import * as todoContracts from "@/contracts/todos";

// Create the base client
export const apiClient = createClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000",
  headers: async () => {
    // Add authentication headers if needed
    // const token = await getAuthToken();
    // return { Authorization: `Bearer ${token}` };
    return {};
  },
});

// Export typed endpoints
export const todosApi = {
  list: (query?: Parameters<typeof todoContracts.listTodos.call>[0]['query']) =>
    apiClient.endpoint(todoContracts.listTodos).call({ query: query ?? {} }),
  
  get: (id: string) =>
    apiClient.endpoint(todoContracts.getTodo).call({ path: { id } }),
  
  create: (data: Parameters<typeof todoContracts.createTodo.call>[0]['body']) =>
    apiClient.endpoint(todoContracts.createTodo).call({ body: data }),
  
  update: (id: string, data: Parameters<typeof todoContracts.updateTodo.call>[0]['body']) =>
    apiClient.endpoint(todoContracts.updateTodo).call({ path: { id }, body: data }),
  
  delete: (id: string) =>
    apiClient.endpoint(todoContracts.deleteTodo).call({ path: { id } }),
};

Step 7: Use the Client in Your Frontend

Use your typed client in React components:
// app/todos/page.tsx
"use client";

import { useEffect, useState } from "react";
import { todosApi } from "@/lib/api-client";

// Define the Todo type for better type safety
interface Todo {
  id: string;
  title: string;
  description?: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
}

export default function TodosPage() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    loadTodos();
  }, []);

  const loadTodos = async () => {
    setLoading(true);
    try {
      const result = await todosApi.list({ completed: undefined });
      setTodos(result.todos);
    } catch (error) {
      console.error("Failed to load todos:", error);
    } finally {
      setLoading(false);
    }
  };

  const handleCreate = async (title: string) => {
    try {
      await todosApi.create({ title, description: undefined });
      await loadTodos();
    } catch (error) {
      console.error("Failed to create todo:", error);
    }
  };

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <h1>Todos</h1>
      {/* Your UI here */}
    </div>
  );
}
For a better developer experience, use @contract-kit/react-query for automatic caching, loading states, and mutations.

Environment Variables

Create a .env.local file for local development:
# .env.local
NEXT_PUBLIC_API_URL=http://localhost:3000
For production:
# .env.production
NEXT_PUBLIC_API_URL=https://your-domain.com

Testing Your API

Run your development server:
npm run dev
Test your endpoints with curl:
# List todos
curl http://localhost:3000/api/todos

# Create a todo
curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Contract Kit","description":"Complete the setup guide"}'

# Get a todo (replace {id} with actual UUID)
curl http://localhost:3000/api/todos/{id}

# Update a todo
curl -X PUT http://localhost:3000/api/todos/{id} \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'

# Delete a todo
curl -X DELETE http://localhost:3000/api/todos/{id}

Advanced Configuration

Adding Providers

Replace the in-memory provider with real services:
// lib/server.ts
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 ({ req, ports }) => ({
    requestId: crypto.randomUUID(),
    ports, // Providers are now available here
  }),
  // ... rest of config
});
See the Providers documentation for available providers.

Adding Middleware

Middleware runs before your route handlers:
// lib/server.ts
import { createNextServer } from "@contract-kit/next";

export const server = await createNextServer({
  ports,
  middleware: [
    // Custom logging middleware
    async (ctx, next) => {
      const start = Date.now();
      console.log(`[${ctx.requestId}] ${ctx.req.method} ${ctx.req.url}`);
      
      const response = await next();
      
      const duration = Date.now() - start;
      console.log(`[${ctx.requestId}] ${response.status} (${duration}ms)`);
      
      return response;
    },
  ],
  // ... rest of config
});

Using Use Cases

Separate business logic from HTTP handlers:
// use-cases/todos/get-todo.ts
import type { AppPorts } from "@/lib/ports";

export async function getTodoUseCase(
  input: { id: string },
  ports: AppPorts
) {
  const todo = await ports.db.todos.findById(input.id);
  if (!todo) {
    throw new Error(`Todo with id ${input.id} not found`);
  }
  return todo;
}
// lib/server.ts (with routes array)
import * as todoUseCases from "@/use-cases/todos";

export const server = await createNextServer({
  // ... other config
  routes: defineRoutes<AppContext>([
    {
      contract: todoContracts.getTodo,
      useCase: todoUseCases.getTodoUseCase,
      mapInput: ({ path }) => ({ id: path.id }),
    },
  ]),
});
Or with per-route files:
// app/api/todos/[id]/route.ts
import { server } from "@/lib/server";
import { getTodo } from "@/contracts/todos";
import { getTodoUseCase } from "@/use-cases/todos/get-todo";

export const GET = server
  .route(getTodo)
  .useCase(getTodoUseCase, {
    mapInput: ({ path }) => ({ id: path.id }),
    mapOutput: (result) => result,
  });

Server Component Integration

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/todos";

export default async function TodosPage() {
  // Create context from Next.js runtime (headers, cookies, etc.)
  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 ({result.total})</h1>
      <ul>
        {result.todos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? '' : ''} {todo.title}
          </li>
        ))}
      </ul>
    </div>
  );
}
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
Note: This only works in React Server Components (not Client Components).

OpenAPI Documentation

Generate OpenAPI documentation for your API:
// app/api/openapi/route.ts
import { contractsToOpenAPI } from "@contract-kit/openapi";
import * as todoContracts from "@/contracts/todos";

export async function GET() {
  const spec = contractsToOpenAPI(
    [
      todoContracts.listTodos,
      todoContracts.getTodo,
      todoContracts.createTodo,
      todoContracts.updateTodo,
      todoContracts.deleteTodo,
    ],
    {
      title: "Todos API",
      version: "1.0.0",
      description: "A simple todo API built with Contract Kit",
    }
  );

  return Response.json(spec);
}
Visit /api/openapi to see your generated OpenAPI spec. See the OpenAPI Generation guide for more details.

Deployment

Contract Kit works seamlessly with Next.js deployment platforms:

Vercel

  1. Push your code to GitHub
  2. Import your project in Vercel
  3. Add environment variables
  4. Deploy

Docker

# Dockerfile
FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT 3000

CMD ["node", "server.js"]

Troubleshooting

TypeScript Errors

Ensure you’re using TypeScript 5.0 or higher:
// package.json
{
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

“Module not found” errors

Make sure your tsconfig.json includes path aliases:
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}

Runtime errors with providers

Ensure providers are properly initialized. Check your provider setup without exposing sensitive credentials:
// Check provider initialization
if (!ctx.ports.db) {
  console.error("Database provider is not initialized. Check your provider configuration.");
}

// Verify environment variables are set (without logging their values)
if (process.env.DATABASE_URL) {
  console.log("DATABASE_URL is configured");
} else {
  console.error("DATABASE_URL environment variable is not set");
}

Next Steps

Additional Resources