Zod 4 (and all other Standard Schema support) is available as a release candidate `3.53.0-rc.1`
ts-rest
Client

Fetch

The simple fetch client provides a lightweight, type-safe way to make HTTP requests using your ts-rest contract.

The simple fetch client is ts-rest's default HTTP client implementation, built on top of the standard Fetch API. It provides automatic type inference, request/response handling, and seamless integration with your contract definitions.

Basic Usage

Import initClient from @ts-rest/core and pass your contract to get a fully typed client:

client.ts
import {  } from '@ts-rest/core';

const  = (, {
  : 'https://api.example.com',
  : {},
});

const  = await ..({
  : { : '1' },
});

if (. === 200) {
  .(result..); // Fully typed!
const result: {
    status: 200;
    body: {
        type: string;
        id: string;
        name: string;
    };
    headers: Headers;
}
} else { .(result.);
const result: {
    status: 204 | 100 | 101 | 102 | 201 | 202 | 203 | 205 | 206 | 207 | 300 | 301 | 302 | 303 | 304 | 305 | 307 | ... 37 more ... | 511;
    body: unknown;
    headers: Headers;
}
}

Client Configuration

Base Configuration

Configure your client with base settings that apply to all requests:

client-config.ts
const  = (, {
  : 'https://api.example.com',
  : {
    'X-API-Key': 'your-api-key',
  },
  : 'include', // For sending cookies
  : true, // Validate responses against schema
  : true, // Throw on unexpected status codes
});

Dynamic Headers

You can provide headers as functions for dynamic values:

dynamic-headers.ts
import { initClient } from '@ts-rest/core';
import { getAccessToken } from './auth';
import { contract } from './contract';

const client = initClient(contract, {
  baseUrl: 'https://api.example.com',
  baseHeaders: {
    Authorization: () => `Bearer ${getAccessToken()}`,
  },
});

Making Requests

Query Requests (GET)

Any endpoint using the GET method is treated as a query request:

queries.ts
// Simple GET with path parameters
const  = await .({
  : { : '25' },
});

// GET with query parameters
const  = await .({
  : {
    : 'electric',
    : 10,
  },
});

// GET with no parameters
const  = await .();

Mutation Requests (POST, PUT, PATCH, DELETE)

Any endpoint using POST, PUT, PATCH, or DELETE methods are treated as mutation requests:

mutations.ts
// POST with body
const  = await .({
  : {
    : 'Pikachu',
    : 'Electric',
  },
});

// PUT with path params and body
const  = await .({
  : { : '25' },
  : {
    : 'Pikachu',
    : 'Electric',
  },
});

// DELETE with path params
const  = await .({
  : { : '25' },
});

Request Parameters

Breaking Down the Request Object

Each request accepts an object with the following optional properties:

  • params - Path parameters for the URL
  • query - Query string parameters
  • headers - Request headers (merged with base headers)
  • extraHeaders - Headers not defined in the contract
  • body - Request body for mutations
  • fetchOptions - Additional fetch options
  • overrideClientOptions - Override client settings for this request
  • cache - Shorthand for fetchOptions.cache
request-params.ts
const  = await .({
  : { : '25' },
  : { : true },
  : { 'x-api-version': 'v2' },
  : { 'x-request-id': 'abc123' },
  : {
    : 'Raichu',
    : 'Electric',
  },
  : {
    : .,
  },
  : 'no-store',
});

Response Handling

Understanding Response Types

The fetch client returns a discriminated union based on the status code, allowing for precise type checking:

response-types.ts
const  = await .({
  : { : '25' },
});

// Type-safe status checking
if (. === 200) {
  // result.body is typed as Pokemon
  ..name;
name: string
..('Content-Type'); } else if (. === 404) { // result.body is typed as { message: string } ..message;
message: string
} else { // result.body is unknown for other status codes .status;
status: 100 | 101 | 102 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 300 | 301 | 302 | 303 | 304 | 305 | 307 | 308 | 400 | 401 | 402 | 403 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | ... 20 more ... | 511
}

The response includes three properties: status (the HTTP status code), body (the parsed response body), and headers (a Headers object with response headers).

Content Types

JSON (Default)

By default, all requests use application/json content type:

json-content.ts
// Automatically serialized as JSON
const  = await .({
  : {
    : 'Pikachu',
    : 'Electric',
  },
});

Form Data (multipart/form-data)

For file uploads, use the multipart/form-data content type:

form-data.ts
// Single file upload
const  = new (['image data'], 'pikachu.jpg', { : 'image/jpeg' });
const  = await .({
  : { : '25' },
  : {
    : ,
    : 'Pikachu headshot',
  },
});

// Multiple file upload
const  = [
  new (['image1'], 'front.jpg', { : 'image/jpeg' }),
  new (['image2'], 'back.jpg', { : 'image/jpeg' }),
];
const  = await .({
  : { : '25' },
  : {
    : ,
  },
});

// You can also pass FormData directly
const  = new ();
.('image', );
.('description', 'Custom form data');

const  = await .({
  : { : '25' },
  : ,
});

URL Encoded Forms (application/x-www-form-urlencoded)

For traditional form submissions:

url-encoded.ts
// Automatically converted to URLSearchParams
const  = await .({
  : {
    : 'Ash Ketchum',
    : 'ash@pokemon.com',
    : 'Gotta catch em all!',
  },
});

// You can also pass a string directly
const  = await .({
  : 'name=Ash&email=ash@pokemon.com&message=Hello',
});

Advanced Features

JSON Query Parameters

Enable JSON encoding for complex query parameters:

json-query.ts
const  = await .({
  : {
    : {
      : 'Electric',
      : 50,
    },
    : {
      : 'name',
      : 'asc',
    },
  },
});

Important: When using jsonQuery, make sure your server is configured to parse JSON-encoded query parameters. Objects with .toJSON() methods (like Date) will be irreversibly converted to their JSON representation.

Response Validation

Enable automatic response validation against your contract schemas:

response-validation.ts
const  = await .({
  : { : '25' },
});

if (. === 200) {
  // result.body.createdAt is automatically transformed to Date
  ..createdAt instanceof ; // true
createdAt: Date
}

Response validation only works with validation schemas (Zod, Valibot, etc.). Plain TypeScript types (c.type<>()) are not validated at runtime.

Strict Status Codes

Enforce that only status codes defined in your contract are allowed:

strict-status.ts
import { initClient } from '@ts-rest/core';
import { contract } from './contract'; // Contract with strictStatusCodes: true

const client = initClient(contract, {
  baseUrl: 'https://api.example.com',
  throwOnUnknownStatus: true, // Throw on unexpected status codes
});

// This will throw an error if server returns a status code
// not defined in the contract
const result = await client.getPokemon({
  params: { id: '25' },
});

Credentials and Cookies

Configure credential handling for cross-origin requests:

credentials.ts
import { initClient } from '@ts-rest/core';
import { contract } from './contract';

const client = initClient(contract, {
  baseUrl: 'https://api.example.com',
  credentials: 'include', // Send cookies with requests
});

Fetch Options

Pass additional fetch options for fine-grained control:

fetch-options.ts
const  = await .({
  : { : '25' },
  : {
    : .,
    : 'cors',
    : 'no-cache',
  },
  // Shorthand for fetchOptions.cache
  : 'force-cache',
});

// Cancel the request
.();

Next.js Integration

The fetch client works seamlessly with Next.js features:

nextjs.ts
import { initClient } from '@ts-rest/core';
import { contract } from './contract';

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

// Use Next.js revalidation
const result = await client.getPokemon({
  params: { id: '25' },
  fetchOptions: {
    next: {
      revalidate: 3600, // Revalidate every hour
      tags: ['pokemon', 'pokemon-25'],
    },
  },
});

Custom API Implementation

While the default fetch implementation covers most use cases, you can provide a custom api function for advanced scenarios like request/response interceptors, custom error handling, or using alternative HTTP clients.

Basic Custom API

custom-api.ts
const console = {
  log: (message: string) => {},
};

// ---cut---
import { initClient, tsRestFetchApi, ApiFetcherArgs } from '@ts-rest/core';
import { contract } from './contract';

const client = initClient(contract, {
  baseUrl: 'https://api.example.com',
  api: async (args: ApiFetcherArgs) => {
    // Add logging
    console.log(`Making ${args.method} request to ${args.path}`);

    // Call the default implementation
    const result = await tsRestFetchApi(args);

    console.log(`Response: ${result.status}`);
    return result;
  },
});

Custom API with Extra Arguments

Extend the API with custom arguments that are fully typed:

custom-api-args.ts
const  = (, {
  : 'https://api.example.com',
  : async (
    :  & {
      ?: (: number) => void;
      ?: string;
    },
  ) => {
    // Handle custom arguments
    if (.) {
      .(0);
    }

    if (.) {
      // Do something with myCustomArg ✨
      .(`Custom arg received: ${.}`);
    }

    const  = await ();

    .?.(100);
    return ;
  },
});

// Now you can use the custom arguments with full type safety and autocomplete! 🤯
const  = await .({
  : { : new ([''], 'test.txt') },
  : () => {
    .(`Upload progress: ${}%`);
  },
});
const  = await .({
  : { : 0, : 10 },
  my
  • myCustomArg
});

Magic of Type Safety: The custom arguments become fully typed and work with your IDE's autocomplete! You can use this pattern to accomplish many advanced scenarios like adding cache arguments, logger arguments, upload progress tracking, or any other custom functionality your API needs.

Important: Any extra arguments you provide will be passed to your API function, even if they're not properly typed (e.g., if you've used @ts-expect-error). This is because the args parameter spreads all the arguments you pass to your API calls.

Using Alternative HTTP Clients

You can completely replace the fetch implementation with libraries like Axios:

axios-client.ts
import axios, { AxiosError, isAxiosError } from 'axios';
import { initClient, ApiFetcherArgs } from '@ts-rest/core';
import { contract } from './contract';

const client = initClient(contract, {
  baseUrl: 'https://api.example.com',
  api: async (args: ApiFetcherArgs) => {
    try {
      const result = await axios.request({
        method: args.method,
        url: `${args.baseUrl}${args.path}`,
        headers: args.headers,
        data: args.body,
        params: args.query,
      });

      return {
        status: result.status,
        body: result.data,
        headers: new Headers(result.headers as Record<string, string>),
      };
    } catch (error) {
      if (isAxiosError(error) && error.response) {
        return {
          status: error.response.status,
          body: error.response.data,
          headers: new Headers(
            error.response.headers as Record<string, string>,
          ),
        };
      }
      throw error;
    }
  },
});

Ready to make requests?

🚀 Not quite enough? - checkout React Query client for React applications with caching and state management