Quickstart
Embarking on your journey with ts-rest is a breeze! You have the liberty to utilize just the front-end, the backend, or merely the contract (should you choose to do so).
Once you've mastered the fundamentals, you'll find the developer experience to be smooth as silk. ✨
Installation​
Install the core package and zod - Some of our generics rely on zod being installed, so make sure to install it (even as a dev dependency if you don't plan to use Zod)
If you don't install zod, some confusing errors may appear but it should mostly work #303
- pnpm
- npm
- yarn
pnpm add @ts-rest/core zod
npm install @ts-rest/core zod
yarn add @ts-rest/core zod
Create a contract​
- Zod
- Basic
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.
strict with Zod// contract.ts
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
const PostSchema = z.object({
id: z.string(),
title: z.string(),
body: z.string(),
});
export const contract = c.router({
createPost: {
method: 'POST',
path: '/posts',
responses: {
201: PostSchema,
},
body: z.object({
title: z.string(),
body: z.string(),
}),
summary: 'Create a post',
},
getPost: {
method: 'GET',
path: `/posts/:id`,
responses: {
200: PostSchema.nullable(),
},
summary: 'Get a post by id',
},
});
Zod also has some nice features, like enabling body parsing and OpenAPI type generation.
// contract.ts
import { initContract } from '@ts-rest/core';
const c = initContract();
export const contract = c.router({
createPost: {
method: 'POST',
path: '/posts',
responses: {
201: c.type<Post>(),
},
body: c.type<{title: string}>(),
summary: 'Create a post',
},
getPost: {
method: 'GET',
path: `/posts/:id`,
responses: {
200: c.type<Post | null>(),
},
summary: 'Get a post by id',
},
});
Server Implementation​
- Nest.js
- Express
- Fastify
- Next.js
- pnpm
- npm
- yarn
pnpm add @ts-rest/nest
npm install @ts-rest/nest
yarn add @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:
// post.controller.ts
const c = nestControllerContract(contract);
type RequestShapes = NestRequestShapes<typeof c>;
@Controller()
export class PostController implements NestControllerInterface<typeof c> {
constructor(private readonly postService: PostService) {}
@TsRest(c.getPost)
async getPost(@TsRestRequest() { params: { id } }: RequestShapes['getPost']) {
const post = await this.postService.getPost(id);
return { status: 200 as const, body: post };
}
@TsRest(c.createPost)
async createPost(@TsRestRequest() { body }: RequestShapes['createPost']) {
const post = await this.postService.createPost({
title: body.title,
body: body.body,
});
return { status: 201 as const, body: post };
}
}
You can see that we're using the runtime object c in the TsRest decorator to automatically declare your path from the contract's getPost route.
We're also using the RequestShapes Typescript Types (which comes from the runtime object c) to ensure type safety of your contract on the Nest controller.
If you were to change the body return type to { body: true } for example, this will give you a typescript error: Your body is defined as an object in the contract above, not boolean!
- pnpm
- npm
- yarn
pnpm add @ts-rest/express
npm install @ts-rest/express
yarn add @ts-rest/express
The express implementaton allows full type safety, offering; body parsing, query parsing, param parsing and full error handling
// main.ts
import { initServer } from '@ts-rest/express';
const app = express();
app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
const s = initServer();
const router = s.router(contract, {
getPost: async ({ params: { id } }) => {
const post = await prisma.post.findUnique({ where: { id } });
return {
status: 200,
body: post,
};
},
createPost: async ({ body }) => {
const post = await prisma.post.create({
data: body,
});
return {
status: 201,
body: post,
};
},
});
createExpressEndpoints(contract, router, app);
const port = process.env.port || 3333;
const server = app.listen(port, () => {
console.log(`Listening at http://localhost:${port}`);
});
- pnpm
- npm
- yarn
pnpm add @ts-rest/fastify
npm install @ts-rest/fastify
yarn add @ts-rest/fastify
The fastify implementaton allows full type safety, offering; body parsing, query parsing, param parsing and full error handling
// main.ts
import { initServer } from '@ts-rest/fastify';
const app = fastify();
const s = initServer();
const router = s.router(contract, {
getPost: async ({ params: { id } }) => {
const post = await prisma.post.findUnique({ where: { id } });
return {
status: 200,
body: post,
};
},
createPost: async ({ body }) => {
const post = await prisma.post.create({
data: body,
});
return {
status: 201,
body: post,
};
},
});
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
- npm
- yarn
pnpm add @ts-rest/next
npm install @ts-rest/next
yarn add @ts-rest/next
// pages/api/[...ts-rest].tsx
// `contract` is the AppRouter returned by `c.router`
const postsRouter = createNextRoute(contract.posts, {
createPost: async (args) => {
const newPost = await posts.createPost(args.body);
return {
status: 201,
body: newPost,
};
},
});
const router = createNextRoute(contract, {
posts: postsRouter,
});
// Actually initiate the collective endpoints
export default createNextRouter(contract, router);
Client Implementation​
- Fetch
- React Query
This is the basic client, using fetch under the hood which is exported from @ts-rest/core.
// client.ts
import { initClient } from "@ts-rest/core";
// `contract` is the AppRouter returned by `c.router`
const client = initClient(contract, {
baseUrl: 'http://localhost:3000',
baseHeaders: {},
});
const { body, status } = await client.createPost({
body: {
title: 'Post Title',
body: 'Post Body',
},
});
if (status === 201) {
// body is Post
console.log(body);
} else {
// body is unknown
console.log(body);
}
- pnpm
- npm
- yarn
pnpm add @ts-rest/react-query
npm install @ts-rest/react-query
yarn add @ts-rest/react-query
// client.ts
import { initQueryClient } from "@ts-rest/react-query";
// `contract` is the AppRouter returned by `c.router`
export const client = initQueryClient(contract, {
baseUrl: 'http://localhost:3333',
baseHeaders: {},
});
export const Index = () => {
const { data, isLoading, error } = client.getPost.useQuery(["posts/1"], {
params: { id: '1' },
});
if (isLoading) {
return <div>Loading...</div>;
}
if (data.status !== 200 || error) {
return <div>Error</div>;
}
return <div>{data.body.title}</div>;
};
The response from react-query is typed as follows:
If status is 2XX, it's part of the "data" return. If it's any other status code it's part of the "error" return e.g.
const data:
| {
status: 200;
body: Post | null;
}
| undefined;
const error:
| {
status: 404;
body: null
}
| {
status: 404 | 100 | 101 | 102 | 300 | 301 | 302 | 303 | 304 | 305 | 307 | 308 | 400 | 401 | 402 | 403 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | ... 16 more ... | 511;
body: unknown;
}
| null