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 @contract-kit/ports
You can swap Zod for any Standard Schema library — Valibot, ArkType, etc.
Add @contract-kit/openapi separately if you want OpenAPI generation. OpenAPI generation currently requires Zod schemas.
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");
const ErrorSchema = z.object({
code: z.string(),
message: z.string(),
});
export const getTodo = todos
.get("/api/todos/:id")
.pathParams(z.object({ id: z.string() }))
.responses({
200: z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
}),
404: ErrorSchema,
});
Set up ports
Ports define your application's external dependencies. This first route uses a tiny in-memory port so the handler has something real to call without introducing a database provider yet.
// app/server/ports.ts
import { definePorts } from "@contract-kit/ports";
export const ports = definePorts({
db: {
todos: {
findById: async (id: string) =>
id === "123"
? { id, title: "Example Todo", completed: false }
: null,
},
},
});
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,
}),
onError: () => ({
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
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);
if (!todo) {
return {
status: 404,
body: { code: "TODO_NOT_FOUND", message: "Todo not found" },
};
}
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 { createNextClient } from "@contract-kit/next";
import { getTodo } from "@/app/contracts/todo";
const client = createNextClient();
export const getTodoEndpoint = client.endpoint(getTodo);
Call the API
const todo = await getTodoEndpoint.call({
path: { id: "123" },
});
console.log(todo.title); // fully typed
Use call() when you want failed responses to throw:
import { isContractError } from "@contract-kit/client";
try {
await getTodoEndpoint.call({ path: { id: "missing" } });
} catch (error) {
if (isContractError(error, 404)) {
console.log(error.body.code); // "TODO_NOT_FOUND"
}
}
Use safeCall() when you want an explicit result instead of exceptions:
const result = await getTodoEndpoint.safeCall({ path: { id: "missing" } });
if (!result.ok) {
console.log(result.error.body);
}
The client knows exactly what parameters to send and what each declared response looks like — all inferred from the contract.