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.