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

Quickstart

Let's get started, making a basic contract, server, and client.

Create a contract with @ts-rest/core

Create a server implementation with one of our supported frameworks

Create a client implementation with one of our supported libraries

Installation

Install the @ts-rest/core package, this is the core package that provides the type-safe contract, and a basic fetch based client.

pnpm add @ts-rest/core
bun add @ts-rest/core
npm install @ts-rest/core

Enable strict in your tsconfig.json, this is required to work with some ts-rest functionality, and often downstream libraries like Zod

tsconfig.json
  "compilerOptions": {
    "strict": false
    "strict": true
  }

Create a contract

This should ideally be shared between your consumers and producers, e.g. in a shared library in a monorepo, or a shared npm package. Think of this as your HTTP Schema that both your client and backend can use.

Shared contract

We strongly reccomend using a validation library like Zod, Valibot, or Arktype to define your contract.

This provides runtime validation of your contract, as opposed to just type safety.

contract.ts
import { initContract } from '@ts-rest/core';
import { z } from 'zod';

const c = initContract();

const Pokemon = z.object({
  name: z.string(),
});

export const pokemonContract = c.router({
  getPokemon: {
    method: 'GET',
    path: '/pokemon/:id',
    responses: {
      200: Pokemon,
    },
    summary: 'Get a pokemon by id',
  },
});
contract.ts
import { initContract } from '@ts-rest/core';
import * as v from 'valibot';

const c = initContract();

const Pokemon = v.object({
  name: v.string(),
});

export const pokemonContract = c.router({
  getPokemon: {
    method: 'GET',
    path: '/pokemon/:id',
    responses: {
      200: Pokemon,
    },
    summary: 'Get a pokemon by id',
  },
});
contract.ts
import { initContract } from '@ts-rest/core';
import { type } from 'arktype';

const c = initContract();

const Pokemon = type({
  name: 'string',
});

export const pokemonContract = c.router({
  getPokemon: {
    method: 'GET',
    path: '/pokemon/:id',
    responses: {
      200: Pokemon,
    },
    summary: 'Get a pokemon by id',
  },
});
contract.ts
import { initContract } from '@ts-rest/core';

const c = initContract();

type Pokemon = {
  name: string;
};

export const pokemonContract = c.router({
  getPokemon: {
    method: 'GET',
    path: '/pokemon/:id',
    responses: {
      200: c.type<Pokemon>(),
    },
    summary: 'Get a pokemon by id',
  },
});

Server Implementation

pnpm add @ts-rest/nest
bun add @ts-rest/nest
npm install @ts-rest/nest

ts-rest offers a unique way to create a fully type safe REST API server, normally Nest APIs are extremely powerful, but hard to make type safe.

Let's add @ts-rest/nest to a basic Nest controller:

pokemon.controller.ts
import { TsRestHandler, tsRestHandler } from '@ts-rest/nest';
import { Controller } from '@nestjs/common';
import { pokemonContract } from './contract';

@Controller()
export class PokemonController {
  constructor(private readonly pokemonService: PokemonService) {}

  @TsRestHandler(pokemonContract.getPokemon)
  async getPokemon() {
    return tsRestHandler(pokemonContract.getPokemon, async ({ params }) => {
      const pokemon = await this.pokemonService.getPokemon(params.id);

      if (!pokemon) {
        return { status: 404, body: null };
      }

      return { status: 200, body: pokemon };
    });
  }
}

You can see that we're using the modern @TsRestHandler decorator with the tsRestHandler function to get full type safety. The params are automatically typed based on your contract, and the return type is enforced to match your contract's response schema.

pnpm add @ts-rest/express
bun add @ts-rest/express
npm install @ts-rest/express

The express implementaton allows full type safety, offering; body parsing, query parsing, param parsing and full error handling

main.ts
import express from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';
import { initServer } from '@ts-rest/express';
import { createExpressEndpoints } from '@ts-rest/express';
import { pokemonContract } from './contract';

const app = express();

app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

const s = initServer();

const router = s.router(pokemonContract, {
  getPokemon: async ({ params: { id } }) => {
    // Mock pokemon data
    const pokemon = { name: 'Pikachu' };

    if (id !== '1') {
      return {
        status: 404,
        body: null,
      };
    }

    return {
      status: 200,
      body: pokemon,
    };
  },
});

createExpressEndpoints(pokemonContract, router, app);

const port = process.env.port || 3333;
const server = app.listen(port, () => {
  console.log(`Listening at http://localhost:${port}`);
});
pnpm add @ts-rest/fastify
bun add @ts-rest/fastify
npm install @ts-rest/fastify

The fastify implementaton allows full type safety, offering; body parsing, query parsing, param parsing and full error handling

main.ts
import fastify from 'fastify';
import { initServer } from '@ts-rest/fastify';
import { pokemonContract } from './contract';

const app = fastify();

const s = initServer();

const router = s.router(pokemonContract, {
  getPokemon: async ({ params: { id } }) => {
    // Mock pokemon data
    const pokemon = { name: 'Pikachu' };

    if (id !== '1') {
      return {
        status: 404,
        body: null,
      };
    }

    return {
      status: 200,
      body: pokemon,
    };
  },
});

app.register(s.plugin(router));

const start = async () => {
  try {
    await app.listen({ port: 3000 });
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
};

start();
pnpm add @ts-rest/next
bun add @ts-rest/next
npm install @ts-rest/next
pages/api/[...ts-rest].tsx
import { createNextRoute, createNextRouter } from '@ts-rest/next';
import { pokemonContract } from './contract';

const router = createNextRoute(pokemonContract, {
  getPokemon: async ({ params: { id } }) => {
    // Mock pokemon data
    const pokemon = { name: 'Pikachu' };

    if (id !== '1') {
      return {
        status: 404,
        body: null,
      };
    }

    return {
      status: 200,
      body: pokemon,
    };
  },
});

// Actually initiate the collective endpoints
export default createNextRouter(pokemonContract, router);

Client Implementation

This is the basic client, using fetch under the hood which is exported from @ts-rest/core.

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

const  = (, {
  : 'http://localhost:3000',
  : {},
});

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

if (. === 200) {
  .body.;
body: Pokemon
} else { res;
const res: {
    status: 100 | 101 | 102 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 300 | 301 | 302 | 303 | 304 | 305 | 307 | 308 | 400 | 401 | 402 | ... 33 more ... | 511;
    body: unknown;
    headers: Headers;
}
}
pnpm add @ts-rest/react-query
bun add @ts-rest/react-query
npm install @ts-rest/react-query

The @ts-rest/react-query integration follows the same underlying pattern as the core @tanstack/react-query package so it should feel super familiar.

pokemon-component.tsx
import {  } from '@ts-rest/react-query';

export const  = (, {
  : 'http://localhost:3333',
  : {},
});

export const  = () => {
  const { , ,  } = ..(
    ['pokemon', '1'],
    {
      : { : '1' },
    },
  );

  if () {
    return <>Loading...</>;
  }

  if (data?. !== 200 || ) {
const data: {
    status: 200;
    body: Pokemon;
    headers: Headers;
} | undefined

The last successfully resolved data for the query.

return <>Pokemon not found</>; } return <>{..}</>; };