Skip to main content

Introduction

This comprehensive guide will walk you through building a complete Todo API with Contract Kit, from setup to deployment.

What We’ll Build

  • ✅ Type-safe REST API with CRUD operations
  • ✅ Request validation with Zod
  • ✅ Clean architecture with ports and providers
  • ✅ Typed client for frontend consumption
  • ✅ OpenAPI documentation

Prerequisites

  • Node.js 18+ or Bun
  • Basic TypeScript knowledge
  • Familiarity with Next.js (optional)

Step 1: Project Setup

Create a new Next.js project:
npx create-next-app@latest my-contract-kit-app
cd my-contract-kit-app
Install Contract Kit:
npm install contract-kit zod
npm install @contract-kit/next

Step 2: Define Your Domain

Create domain types for your application:
// lib/domain/todo.ts
export interface Todo {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export interface CreateTodoData {
  title: string;
  completed?: boolean;
}

export interface UpdateTodoData {
  title?: string;
  completed?: boolean;
}

Step 3: Create Contracts

Define your API contracts:
// lib/contracts/todo.ts
import { createContractGroup } from "contract-kit";
import { z } from "zod";

const TodoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

const todos = createContractGroup().namespace("todos");

export const listTodos = todos
  .get("/api/todos")
  .query(z.object({
    completed: z.boolean().optional(),
  }))
  .response(200, z.object({
    todos: z.array(TodoSchema),
  }));

export const getTodo = todos
  .get("/api/todos/:id")
  .path(z.object({ id: z.string() }))
  .response(200, TodoSchema)
  .errors({ 404: z.object({ message: z.string() }) });

export const createTodo = todos
  .post("/api/todos")
  .body(z.object({
    title: z.string().min(1).max(100),
    completed: z.boolean().optional(),
  }))
  .response(201, TodoSchema);

export const updateTodo = todos
  .patch("/api/todos/:id")
  .path(z.object({ id: z.string() }))
  .body(z.object({
    title: z.string().min(1).max(100).optional(),
    completed: z.boolean().optional(),
  }))
  .response(200, TodoSchema)
  .errors({ 404: z.object({ message: z.string() }) });

export const deleteTodo = todos
  .delete("/api/todos/:id")
  .path(z.object({ id: z.string() }))
  .response(204)
  .errors({ 404: z.object({ message: z.string() }) });

Step 4: Define Ports

Create port interfaces for your dependencies:
// lib/ports.ts
import { definePorts } from "contract-kit";
import type { Todo, CreateTodoData, UpdateTodoData } from "./domain/todo";

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

Step 5: Implement Providers

Create a simple in-memory provider:
// lib/providers/memory-db.ts
import type { Todo, CreateTodoData, UpdateTodoData } from "../domain/todo";

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

export const memoryDbProvider = {
  todos: {
    findAll: async (filter?: { completed?: boolean }) => {
      const allTodos = Array.from(todos.values());
      if (filter?.completed !== undefined) {
        return allTodos.filter(todo => todo.completed === filter.completed);
      }
      return allTodos;
    },

    findById: async (id: string) => {
      return todos.get(id) ?? null;
    },

    create: async (data: CreateTodoData) => {
      const now = new Date();
      const todo: Todo = {
        id: crypto.randomUUID(),
        title: data.title,
        completed: data.completed ?? false,
        createdAt: now,
        updatedAt: now,
      };
      todos.set(todo.id, todo);
      return todo;
    },

    update: async (id: string, data: UpdateTodoData) => {
      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);
    },
  },
};

Step 6: Create the Server

Initialize your Contract Kit server:
// lib/server.ts
import { createNextServer } from "@contract-kit/next";
import { definePorts } from "contract-kit";
import { memoryDbProvider } from "./providers/memory-db";

export const server = await createNextServer({
  ports: definePorts({
    db: memoryDbProvider,
  }),
  createContext: ({ ports, req }) => ({
    requestId: crypto.randomUUID(),
    ports,
  }),
  onUnhandledError: (error) => {
    console.error("Unhandled error:", error);
    return {
      status: 500,
      body: { message: "Internal server error" },
    };
  },
});

Step 7: Implement Routes

Create API route handlers:
// app/api/todos/route.ts
import { server } from "@/lib/server";
import { listTodos, createTodo } from "@/lib/contracts/todo";

export const GET = server.route(listTodos).handle(async ({ ctx, query }) => {
  const todos = await ctx.ports.db.todos.findAll(query);
  return { status: 200, body: { todos } };
});

export const POST = server.route(createTodo).handle(async ({ ctx, body }) => {
  const todo = await ctx.ports.db.todos.create(body);
  return { status: 201, body: todo };
});
// app/api/todos/[id]/route.ts
import { server } from "@/lib/server";
import { getTodo, updateTodo, deleteTodo } from "@/lib/contracts/todo";

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" } };
  }
  return { status: 200, body: todo };
});

export const PATCH = 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" } };
  }
  return { status: 200, body: todo };
});

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

Step 8: Create a Typed Client

Generate a client for your frontend:
// lib/api-client.ts
import { createClient } from "contract-kit";
import {
  listTodos,
  createTodo,
  getTodo,
  updateTodo,
  deleteTodo,
} from "./contracts/todo";

const client = createClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
});

export const apiClient = {
  listTodos: (args) => client.endpoint(listTodos).call(args),
  createTodo: (args) => client.endpoint(createTodo).call(args),
  getTodo: (args) => client.endpoint(getTodo).call(args),
  updateTodo: (args) => client.endpoint(updateTodo).call(args),
  deleteTodo: (args) => client.endpoint(deleteTodo).call(args),
};

Step 9: Use in Frontend

Create a simple UI component:
// app/todos/page.tsx
"use client";

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

export default function TodosPage() {
  const [todos, setTodos] = useState([]);
  const [title, setTitle] = useState("");

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

  const loadTodos = async () => {
    const result = await apiClient.listTodos({ query: {} });
    setTodos(result.todos);
  };

  const handleCreate = async (e: React.FormEvent) => {
    e.preventDefault();
    await apiClient.createTodo({
      body: { title, completed: false },
    });
    setTitle("");
    loadTodos();
  };

  return (
    <div>
      <h1>Todos</h1>
      <form onSubmit={handleCreate}>
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="New todo..."
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            {todo.title} - {todo.completed ? "" : ""}
          </li>
        ))}
      </ul>
    </div>
  );
}

Step 10: Test Your API

Run your development server:
npm run dev
Test your endpoints:
# 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","completed":false}'

# Get a todo
curl http://localhost:3000/api/todos/{id}

# Update a todo
curl -X PATCH 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}

Next Steps