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:Copy
npx create-next-app@latest my-contract-kit-app
cd my-contract-kit-app
Copy
npm install contract-kit zod
npm install @contract-kit/next
Step 2: Define Your Domain
Create domain types for your application:Copy
// 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:Copy
// 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:Copy
// 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:Copy
// 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:Copy
// 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:Copy
// 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 };
});
Copy
// 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:Copy
// 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:Copy
// 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:Copy
npm run dev
Copy
# 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}