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.