---
title: Fetch Client
description: Create a typed Fetch client to call your routes over HTTP.
---

`initClient()` creates a typed Fetch client from a runtime contract. When the
routes are implemented in the server, you need to [Generate a Contract](/docs/client/contract-generation) first.

```ts
import { initClient } from "@rest-rpc/core";
import { api } from "./client-contract";

const client = initClient(api, {
	baseUrl: "https://api.example.com",
});
```

The client has the same route tree shape as the contract.

## Call Routes

The route declaration determines the request and
result shapes. Flat input stays flat, and segmented input uses `params`,
`query`, `headers`, and `body`.

```ts
const todo = await client.todos.create({ title: "Write docs" });
const response = await client.todos.get({ params: { id: todo.id } });
```

Plain output routes return decoded data directly. Status response routes return
a `status`-discriminated union with `body` and the Fetch `Headers` object as
`headers`. Declared response headers are also exposed through `responseHeaders`.

```ts
if (response.status === 200) {
	console.log(response.body.title);
} else if (response.status === 404) {
	console.log(response.body.code);
}
```

Declared non-2xx responses are returned as envelopes. Undeclared statuses throw
an [`HttpError`](#error-handling). Network failures and aborted requests reject
with the error from `fetch`. See the [Route Builder](/docs/route-builder) for
choosing result forms and declaring expected outcomes.

## Error Handling

An `HttpError` is thrown when a response does not match the route contract:

- The status is not declared by the route. A plain output route only accepts its
  successful status.
- The response content type is not one the route declares.
- [Response validation](#response-validation) is enabled and fails. This can
  happen with a 2xx status.

Its `status` is the received HTTP status, and its `body` is the decoded,
unvalidated response body. Validation issues are available through `cause`.

```ts
import { HttpError } from "@rest-rpc/core";

try {
	await client.todos.get({ params: { id: "todo_1" } });
} catch (error) {
	if (error instanceof HttpError) {
		console.log(error.status, error.body);
	} else {
		throw error;
	}
}
```

## Consume Streams

A [streaming route](/docs/streaming) returns an async iterable of `SseEvent<T>`
values. Application data is available on `event.data`:

```ts
export type SseEvent<T> = {
	data: T;
	id?: string;
	event?: string;
	retry?: number;
};
```

```ts
for await (const event of await client.todos.events()) {
	console.log(event.data.message);
	console.log(event.id, event.event, event.retry);
}
```

For a status response stream, narrow the response before iterating its body:

```ts
const response = await client.todos.events();

if (response.status === 200) {
	for await (const event of response.body) {
		console.log(event.data.message);
	}
}
```

The initial request and later stream iteration can fail independently. Pass an
`AbortSignal` in the per-call options to cancel both the request and stream:

```ts
const controller = new AbortController();
const events = await client.todos.events(undefined, {
	signal: controller.signal,
});

controller.abort();
```

### Reconnecting

The client reads SSE events over Fetch but is not a replacement for the
browser `EventSource` API. The `id`, `event`, and `retry` fields are exposed as
metadata only: the client does not reconnect automatically, send the last event
ID, or filter events by name.

To resume a stream, send the last received event ID in the `last-event-id`
header. The server receives it as
[`lastEventId`](/docs/streaming#resume-a-stream).

```ts
let lastEventId: string | undefined;

async function connect() {
	const events = await client.todos.events(undefined, {
		additionalHeaders: {
			"last-event-id": lastEventId,
		},
	});

	for await (const event of events) {
		lastEventId = event.id ?? lastEventId;
		handleTodoEvent(event.data);
	}
}
```

## Client Options

```ts
const client = initClient(api, {
	baseUrl: "https://api.example.com",
	globalHeaders: {
		authorization: () => `Bearer ${readToken()}`,
	},
	timeoutMs: 10_000,
	fetchOptions: {
		credentials: "include",
	},
});
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `baseUrl` | `string` | - | Base URL used to build route requests. |
| `fetch?` | `FetchLike` | - | Custom fetch-compatible function used to send requests. |
| `fetchOptions?` | `ApiClientFetchOptions` | - | Default fetch options merged into every request. Excludes `signal`, which can only be passed per call. |
| `bodyCodecs?` | `readonly BodyCodec<Response>[]` | - | Custom body codecs that take precedence over the defaults. See Serialization and Codecs. |
| `globalHeaders?` | `ClientHeaders` | - | Headers added to every request. Each value may be static or provided for each request. |
| `timeoutMs?` | `number` | - | Aborts a request when fetch does not return in time. |
| `validateResponses?` | `boolean` | `false` | Validates response bodies, declared response headers, and stream event data against the contract's schemas. |

See [Serialization and Codecs](/docs/http-behavior/serialization) for the default body codecs
and how to override them with `bodyCodecs`.

## Global Headers

`globalHeaders` adds headers to every request. Values can be static or provided
by synchronous or asynchronous functions. A provider is useful for values that
can change during the lifetime of the client, such as an access token.

```ts
const client = initClient(api, {
	baseUrl: "https://api.example.com",
	globalHeaders: {
		authorization: () => `Bearer ${readToken()}`,
		"x-client-version": "2026-09",
	},
});
```

A global header with a guaranteed value makes a matching declared route header
optional in client calls. A provider that can return `undefined` does not.

Routes that [combine `.input()` with `.headers()`](/docs/route-builder#combine-input-and-headers)
only accept the input in client calls, so their declared headers must be
supplied through `globalHeaders`.

## Custom Fetch

If you need to wrap the fetch client with custom logic, pass a `fetch` function.

```ts
const client = initClient(api, {
	baseUrl: "https://api.example.com",
	fetch: async (url, init) => {
		const startedAt = performance.now();

		try {
			return await fetch(url, init);
		} finally {
			console.log("API request took", performance.now() - startedAt);
		}
	},
});
```

## Per-Call Options

Pass fetch options as the second argument. They override matching defaults in
`fetchOptions`. Use `signal` for cancellation. `timeoutMs` still applies when a
`signal` is passed.

```ts
const response = await client.todos.get(
	{
		params: { id: "todo_1" },
		headers: { "x-request-id": "req_1" },
	},
	{
		cache: "no-store",
	},
);
```

Use `additionalHeaders` for transport headers needed only by this call. Headers
declared by the route remain part of the typed request object.

```ts
const response = await client.todos.get(
	{ params: { id: "todo_1" } },
	{
		additionalHeaders: {
			"x-trace-id": traceId,
		},
	},
);
```

When a route declares a list of request content types, `contentType` selects
the one used to encode the body and is required:

```ts
await client.files.upload(bytes, { contentType: "image/png" });
```

If you need to pass options to a route without request input, pass `undefined` for the request input.

```ts
const response = await client.health(undefined, {
	cache: "no-store",
});
```

## Response Validation

The client decodes response bodies without schema validation by default.
Enable `validateResponses` to validate decoded bodies, declared response
headers, and stream event data:

```ts
const client = initClient(api, {
	baseUrl: "https://api.example.com",
	validateResponses: true,
});
```

The client receives the schema output on success, and a failure throws an
[`HttpError`](#error-handling). The decoded response is the server's schema
output, but the client validates it as schema input. Schemas whose input and
output types differ, such as transforms, may fail on the client.

:::note
Validation requires runtime schemas, so it only applies to a
[shared contract](/docs/contract-first). [Generated client contracts](/docs/client/contract-generation)
contain no schemas, and the option has no effect on them.
:::

## Type Helpers

Use the type helpers when a client request or result type needs to be reused,
for example in your own wrapper functions.

```ts twoslash
import type {
	InferClientError,
	InferClientRequest,
	InferClientResponse,
	InferClientStreamData,
	InferClientSuccess,
} from "@rest-rpc/core";
import { route } from "@rest-rpc/express";
import z from "zod";

const update = route
	.patch("/todos/:id")
	.params(z.object({ id: z.string() }))
	.body(z.object({ title: z.string() }))
	.handler(({ params, body }) => ({ id: params.id, title: body.title }));

const getFirst = route.handler(() => {
	if (Math.random() < 0.5) {
		return { status: 200, body: { id: "todo_1", title: "Write docs" } };
	}
	return { status: 404, body: { code: "TODO_NOT_FOUND" as const } };
});

type UpdateTodoRequest = InferClientRequest<typeof update>;

type GetFirstTodoResponse = InferClientResponse<typeof getFirst>;

type GetFirstTodoSuccess = InferClientSuccess<typeof getFirst>;

type GetFirstTodoError = InferClientError<typeof getFirst>;

const events = route
	.get("/todos/events")
	.streamOutput(z.object({ id: z.string(), message: z.string() }))
	.handler(async function* () {
		yield { id: "todo_1", message: "Created" };
	});

type TodoEvent = InferClientStreamData<typeof events>;
```

| Type                                       | Infers                                                                                   |
| ------------------------------------------ | ---------------------------------------------------------------------------------------- |
| `InferClientRequest<Route, GlobalHeaders>` | The request passed to the route call, or `undefined` for routes without input.           |
| `InferClientResponse<Route>`               | Every declared response, or the output of a `.output()` route.                           |
| `InferClientSuccess<Route>`                | The declared 2xx responses, or the output of a `.output()` route.                        |
| `InferClientError<Route>`                  | The declared non-2xx responses. `.output()` routes infer `never`.                        |
| `InferClientStreamData<Route>`             | The `data` of each event from a [streaming route](#consume-streams), without `SseEvent`. |

`InferClientError` contains only the responses the client returns. The
[`HttpError`](#error-handling) and `Error` values it throws are not included.

`InferClientRequest` accepts the type of your `globalHeaders` as a second
argument, so headers that `globalHeaders` always provides become optional:

```ts
import type { InferClientRequest } from "@rest-rpc/core";
import type { api } from "./client-contract";

const globalHeaders = { authorization: () => `Bearer ${getToken()}` };

type UpdateTodoRequest = InferClientRequest<
	typeof api.todos.update,
	typeof globalHeaders
>;
```

`ApiClientFor<typeof api>` is the type of the whole client created from a
contract.

Each helper also accepts a route tree and infers a matching tree of types, so
one exported type covers every route:

```ts
import type { InferClientResponse } from "@rest-rpc/core";
import type { api } from "./client-contract";

export type ClientResponses = InferClientResponse<typeof api>;

type TodoResponse = ClientResponses["todos"]["get"];
```
