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
});