Getting Started
Everything you need to go from zero to a working type-safe API endpoint.
Install
bun add contract-kit zod @contract-kit/next
You can swap Zod for any Standard Schema library — Valibot, ArkType, etc.
Define a contract
A contract describes the shape of an API endpoint: its method, path, parameters, and response.
// app/contracts/todo.ts
import { createContractGroup } from "contract-kit";
import { z } from "zod";
const todos = createContractGroup().namespace("todos");
export const getTodo = todos
.get("/api/todos/:id")
.path(z.object({ id: z.string() }))
.response(200, z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
}));
Set up ports
Ports define your application's external dependencies. This keeps your handlers testable and decoupled from infrastructure.
// app/server/ports.ts
import { definePorts } from "contract-kit";
export const ports = definePorts({
db: {
todos: {
findById: async (id: string) => ({
id,
title: "Example Todo",
completed: false,
}),
},
},
});
Create the server
Initialize a Contract Kit server with your ports. The createContext function runs on every request and provides dependencies to your handlers.
// app/server/index.ts
import { createNextServer } from "@contract-kit/next";
import { ports } from "./ports";
export const server = await createNextServer({
ports,
createContext: ({ req, ports }) => ({
requestId: crypto.randomUUID(),
ports,
}),
onUnhandledError: () => ({
status: 500,
body: { message: "Internal server error" },
}),
});
Create a route handler
Use the contract in a Next.js App Router route. The handler receives validated, typed parameters.
// app/api/todos/[id]/route.ts
import { server } from "@/app/server";
import { getTodo } from "@/app/contracts/todo";
export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
const todo = await ctx.ports.db.todos.findById(path.id);
return { status: 200, body: todo };
});
Create a typed client
Generate a type-safe client from the same contract. No separate type definitions needed.
// app/lib/api-client.ts
import { createClient } from "contract-kit";
import { getTodo } from "@/app/contracts/todo";
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
});
export const getTodoEndpoint = client.endpoint(getTodo);
Call the API
const todo = await getTodoEndpoint.call({
path: { id: "123" },
});
console.log(todo.title); // fully typed
The client knows exactly what parameters to send and what the response looks like — all inferred from the contract.