Client

The client gives you a fully typed HTTP client derived from your contracts. No code generation — just TypeScript inference.

Creating a client

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

const client = createNextClient();

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

Making requests

GET with path parameters

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

console.log(todo.title); // fully typed

GET with query parameters

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

console.log(result.todos); // Todo[]

POST with a body

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

console.log(newTodo.id); // string

With custom headers

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

With AbortSignal

const controller = new AbortController();
const todo = await getTodoEndpoint.call({
  path: { id: "123" },
  signal: controller.signal,
});
// Cancel with controller.abort()

React Query automatically passes its signal through queryOptions(), so cancellation works out of the box.

Error handling

call() returns the response body on success and throws a ContractError on non-2xx responses. The isContractError type guard is the recommended way to handle thrown errors — it narrows the status and gives you access to typed helpers.

For declared route-owned error responses, error.body is the parsed and validated response body. Framework-owned errors use Contract Kit's standard { code, message, details?, requestId? } envelope when applicable. Native transport responses can also produce text or an empty body. error.details is only the nested details field from that envelope or local validation details.

import { isContractError } from "@contract-kit/client";

try {
  await getTodoEndpoint.call({ path: { id: "123" } });
} catch (err) {
  if (isContractError(err, 404)) {
    // err.status is narrowed to 404
    console.log("Not found:", err.message);
    console.log("Body:", err.body);
  } else if (isContractError(err)) {
    if (err.hasCode("VALIDATION_ERROR")) {
      console.log("Validation:", err.details);
    }
  }
}

Use safeCall() when you want explicit result handling instead of exceptions:

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

if (result.ok) {
  console.log(result.data.title);
} else if (result.status === 404) {
  console.log("Not found:", result.error.body);
} else {
  console.error(result.error.message);
}

You can also use instanceof directly and the .hasStatus() / .hasCode() methods:

import { ContractError } from "@contract-kit/client";

try {
  await createTodoEndpoint.call({
    body: { title: "New todo" },
  });
} catch (error) {
  if (error instanceof ContractError) {
    if (error.hasStatus(422)) {
      console.log("Validation:", error.details);
    } else {
      console.error(error.status, error.body);
    }
  }
}

Configuration

Global headers

const client = createNextClient({
  headers: () => ({
    "X-Api-Version": "1.0",
  }),
});

Headers can be a function (sync or async) so you can inject tokens dynamically.

Custom fetch

const client = createNextClient({
  fetch: customFetch,
});

Client-side validation

Enable validate: true to validate request parameters against your contract schemas before sending the request. This catches malformed requests early without a round-trip, and the client serializes the parsed values returned by your schema.

const client = createNextClient({
  validate: true,
});

If the body schema accepts undefined such as z.object({ ... }).optional(), you can omit body entirely and the client will send no request body.

Raw request bodies

Use body for contract-validated JSON requests. Use rawBody only when the transport body should be sent as-is, such as FormData, Blob, ArrayBuffer, a stream, or pre-serialized text.

const formData = new FormData();
formData.set("avatar", file);

await uploadAvatarEndpoint.call({
  rawBody: formData,
});

rawBody is not schema-validated or JSON-serialized, and the client does not add Content-Type: application/json for it. Text responses are parsed as strings, so a route can declare z.string() for a text/plain response.

Type safety

The client enforces your contract at the type level. TypeScript will catch mistakes before your code runs.

// TypeScript knows exactly what's required
const todo = await getTodoEndpoint.call({
  path: { id: "123" }, // 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 }, // type error: id must be string
});