tRPC is great but forces you into its ecosystem. ts-rest gives you the same type safety for standard REST APIs.
What is ts-rest?
ts-rest is a type-safe REST API framework. Define your API contract once, get full type safety on both client and server — with standard HTTP methods and paths.
Quick Start
bun add @ts-rest/core
bun add @ts-rest/express # or @ts-rest/next, @ts-rest/fastify
bun add @ts-rest/react-query # for React client
Define the Contract
// contract.ts (shared between client and server)
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
export const contract = c.router({
getUsers: {
method: 'GET',
path: '/users',
responses: {
200: z.array(z.object({
id: z.string(),
name: z.string(),
email: z.string(),
})),
},
query: z.object({
limit: z.number().optional(),
offset: z.number().optional(),
}),
},
getUser: {
method: 'GET',
path: '/users/:id',
responses: {
200: z.object({ id: z.string(), name: z.string(), email: z.string() }),
404: z.object({ message: z.string() }),
},
},
createUser: {
method: 'POST',
path: '/users',
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
responses: {
201: z.object({ id: z.string(), name: z.string(), email: z.string() }),
400: z.object({ message: z.string() }),
},
},
deleteUser: {
method: 'DELETE',
path: '/users/:id',
responses: {
204: z.void(),
404: z.object({ message: z.string() }),
},
},
});
Server Implementation (Express)
import { createExpressEndpoints, initServer } from '@ts-rest/express';
import express from 'express';
import { contract } from './contract';
const app = express();
app.use(express.json());
const s = initServer();
const router = s.router(contract, {
getUsers: async ({ query }) => {
const users = await db.users.findMany({
take: query.limit || 10,
skip: query.offset || 0,
});
return { status: 200, body: users };
},
getUser: async ({ params }) => {
const user = await db.users.findUnique({ where: { id: params.id } });
if (!user) return { status: 404, body: { message: 'User not found' } };
return { status: 200, body: user };
},
createUser: async ({ body }) => {
const user = await db.users.create({ data: body });
return { status: 201, body: user };
},
deleteUser: async ({ params }) => {
await db.users.delete({ where: { id: params.id } });
return { status: 204, body: undefined };
},
});
createExpressEndpoints(contract, router, app);
app.listen(3000);
Client Usage
import { initClient } from '@ts-rest/core';
import { contract } from './contract';
const client = initClient(contract, {
baseUrl: 'http://localhost:3000',
baseHeaders: { Authorization: 'Bearer token' },
});
// Fully typed!
const { status, body } = await client.getUsers({ query: { limit: 10 } });
if (status === 200) {
body.forEach(user => console.log(user.name)); // TypeScript knows this is User[]
}
const newUser = await client.createUser({
body: { name: 'Alice', email: 'alice@example.com' },
});
React Query Integration
import { initQueryClient } from '@ts-rest/react-query';
import { contract } from './contract';
const api = initQueryClient(contract, { baseUrl: '/api' });
function UserList() {
const { data, isLoading } = api.getUsers.useQuery(['users'], {
query: { limit: 20 },
});
if (isLoading) return <p>Loading...</p>;
if (data?.status !== 200) return <p>Error</p>;
return (
<ul>
{data.body.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
ts-rest vs tRPC vs OpenAPI
| Feature | ts-rest | tRPC | OpenAPI |
|---|---|---|---|
| Type Safety | Full | Full | Codegen |
| REST Standard | Yes | No (RPC) | Yes |
| URL Structure | Standard paths | Procedure calls | Standard paths |
| Zod Validation | Built-in | Built-in | Separate |
| React Query | Built-in | Built-in | Generated |
| Cross-language | REST API (any client) | TS only | Any language |
| OpenAPI Export | Yes | Plugin | Native |
Need type-safe data APIs? Check out my Apify actors — structured, validated data extraction. For custom solutions, email spinov001@gmail.com.
Top comments (0)