Skip to main content

Overview

The Contract Kit client provides a fully typed HTTP client with endpoints derived from your contracts. No code generation required - just pure TypeScript inference.

Creating a Client

Basic Setup

import { createClient } from "contract-kit";
import { getTodo, listTodos, createTodo } from "@/contracts/todo";

export const client = createClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
});

export const getTodoEndpoint = client.endpoint(getTodo);
export const listTodosEndpoint = client.endpoint(listTodos);
export const createTodoEndpoint = client.endpoint(createTodo);

With Options

export const client = createClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL!,
  headers: async () => ({
    "X-Api-Version": "1.0",
  }),
});

Making Requests

GET Requests

const todo = await getTodoEndpoint.call({
  path: { id: "123" },
});

console.log(todo.title); // Fully typed!

With Query Parameters

const result = await listTodosEndpoint.call({
  query: {
    completed: true,
    limit: 10,
    offset: 0,
  },
});

console.log(result.todos); // Array of todos

POST Requests

const result = await createTodoEndpoint.call({
  body: {
    title: "New todo",
    completed: false,
  },
});

console.log(result.id); // New todo ID

With Authentication

const todo = await getTodoEndpoint.call({
  path: { id: "123" },
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

Response Handling

Success Responses

The client returns the validated success payload (200/201/204) and throws on non-2xx responses:
const todo = await getTodoEndpoint.call({
  path: { id: "123" },
});

console.log(todo.title);

Error Handling

import { ContractError } from "contract-kit";

try {
  await createTodoEndpoint.call({
    body: { title: "New todo" },
  });
} catch (error) {
  if (error instanceof ContractError) {
    console.error("Request failed:", error.status, error.details);
  } else {
    throw error;
  }
}

Client Configuration

Global Headers

export const client = createClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
  headers: () => ({
    "X-Client-Version": "1.0.0",
  }),
});

Per-Request Headers

const result = await getTodoEndpoint.call({
  path: { id: "123" },
  headers: {
    "X-Request-Id": crypto.randomUUID(),
  },
});

Custom Fetch

import { createClient } from "contract-kit";
import { customFetch } from "./custom-fetch";

export const client = createClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
  fetch: customFetch, // Use custom fetch implementation
});

React Integration

With React Query

import { useQuery } from "@tanstack/react-query";
import { client } from "@/lib/api-client";
import { getTodo } from "@/contracts/todo";

function TodoDetail({ id }: { id: string }) {
  const endpoint = client.endpoint(getTodo);
  const { data, isLoading, error } = useQuery({
    queryKey: ["todo", id],
    queryFn: () => endpoint.call({ path: { id } }),
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <div>{data.title}</div>;
}

With SWR

import useSWR from "swr";
import { client } from "@/lib/api-client";
import { getTodo } from "@/contracts/todo";

function TodoDetail({ id }: { id: string }) {
  const endpoint = client.endpoint(getTodo);
  const { data, error } = useSWR(
    ["todo", id],
    () => endpoint.call({ path: { id } })
  );

  if (error) return <div>Error</div>;
  if (!data) return <div>Loading...</div>;

  return <div>{data.title}</div>;
}

Advanced Usage

Retry Logic

async function fetchWithRetry(fn: () => Promise<any>, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
    }
  }
}

const result = await fetchWithRetry(() =>
  getTodoEndpoint.call({ path: { id: "123" } })
);

Request Interceptors

const originalFetch = window.fetch;

window.fetch = async (input, init) => {
  // Add authentication
  const token = getAuthToken();
  const headers = new Headers(init?.headers);
  headers.set("Authorization", `Bearer ${token}`);

  return originalFetch(input, { ...init, headers });
};

Type Safety

The client provides complete type safety:
// ✅ TypeScript knows exactly what parameters are required
const todo = await getTodoEndpoint.call({
  path: { id: "123" }, // Type error if missing or wrong type
});

// ✅ TypeScript knows the response shape
todo.title; // string
todo.completed; // boolean

// ❌ TypeScript prevents invalid usage
await getTodoEndpoint.call({
  path: { id: 123 }, // Error: id must be string
});

Best Practices

Export one client instance and reuse it throughout your app to avoid duplication.
Always check the status code and handle both success and error cases.
Configure the base URL using environment variables for different environments.
Use global fetch options or interceptors to add authentication headers automatically.
Catch network errors and handle them gracefully in your UI.

Next Steps