ts-rest is a contract-first TypeScript REST framework — initContract() creates a contract builder. c.router({ getPosts: c.query({...}), createPost: c.mutation({...}) }) defines typed endpoints. Each endpoint has method, path (with :param syntax), query, body, and responses. The contract is shared between server and client without code generation. @ts-rest/next provides createNextHandler(contract, router) for App Router. @ts-rest/react-query provides initQueryClient(contract, options) — returns hooks per endpoint: client.posts.getPost.useQuery(), client.posts.createPost.useMutation(). Server implementation: contract.router({ posts: { getPost: async ({ params }) => ({ status: 200, body: post }) } }) — TypeScript enforces all responses match the contract. @ts-rest/core handles the fetch client: initClient(contract, { baseUrl, baseHeaders }). Claude Code generates ts-rest contracts, Next.js route handlers, React Query hooks, and Express server implementations.
CLAUDE.md for ts-rest
## ts-rest Stack
- Version: @ts-rest/core >= 3.45, @ts-rest/next >= 3.45, @ts-rest/react-query >= 3.45 (v5)
- Contract: const c = initContract(); export const contract = c.router({ ... })
- Path params: path: "/posts/:id" — typed as : in z.string()
- Responses: responses: { 200: z.object({...}), 404: z.object({ message: z.string() }) }
- Next handler: createNextHandler(contract.posts, postsRouter, { jsonQuery: true, responseValidation: true })
- Client hook: const client = initQueryClient(contract, { baseUrl: "/api" })
- Query: const { data } = client.posts.getPost.useQuery({ params: { id } })
- Mutation: const { mutateAsync } = client.posts.createPost.useMutation()
API Contract
// lib/api/contract.ts — ts-rest shared contract
import { initContract } from "@ts-rest/core"
import { z } from "zod"
const c = initContract()
// ── Shared schemas ─────────────────────────────────────────────────────────
const PostSchema = z.object({
id: z.string().uuid(),
title: z.string(),
slug: z.string(),
content: z.string(),
excerpt: z.string().nullable(),
published: z.boolean(),
authorId: z.string().uuid(),
tags: z.array(z.string()),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
})
const CreatePostSchema = z.object({
title: z.string().min(5).max(200),
content: z.string().min(20),
excerpt: z.string().max(500).optional(),
tags: z.array(z.string()).default([]),
published: z.boolean().default(false),
})
const UpdatePostSchema = CreatePostSchema.partial()
const PaginatedPostsSchema = z.object({
posts: z.array(PostSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
})
const ErrorSchema = z.object({ message: z.string() })
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
role: z.enum(["user", "admin"]),
createdAt: z.coerce.date(),
})
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
password: z.string().min(8),
})
// ── Contract definition ────────────────────────────────────────────────────
export const contract = c.router(
{
posts: c.router({
list: c.query({
method: "GET",
path: "/posts",
query: z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(10),
search: z.string().optional(),
tag: z.string().optional(),
published: z.coerce.boolean().optional(),
}),
responses: {
200: PaginatedPostsSchema,
},
summary: "List paginated posts",
}),
get: c.query({
method: "GET",
path: "/posts/:id",
pathParams: z.object({ id: z.string().uuid() }),
responses: {
200: PostSchema,
404: ErrorSchema,
},
summary: "Get a post by ID",
}),
getBySlug: c.query({
method: "GET",
path: "/posts/slug/:slug",
pathParams: z.object({ slug: z.string() }),
responses: {
200: PostSchema,
404: ErrorSchema,
},
}),
create: c.mutation({
method: "POST",
path: "/posts",
body: CreatePostSchema,
responses: {
201: PostSchema,
400: ErrorSchema,
401: ErrorSchema,
},
summary: "Create a new post",
}),
update: c.mutation({
method: "PATCH",
path: "/posts/:id",
pathParams: z.object({ id: z.string().uuid() }),
body: UpdatePostSchema,
responses: {
200: PostSchema,
400: ErrorSchema,
401: ErrorSchema,
403: ErrorSchema,
404: ErrorSchema,
},
}),
delete: c.mutation({
method: "DELETE",
path: "/posts/:id",
pathParams: z.object({ id: z.string().uuid() }),
body: c.noBody(),
responses: {
204: c.noBody(),
401: ErrorSchema,
403: ErrorSchema,
404: ErrorSchema,
},
}),
}),
users: c.router({
me: c.query({
method: "GET",
path: "/users/me",
responses: {
200: UserSchema,
401: ErrorSchema,
},
}),
create: c.mutation({
method: "POST",
path: "/users",
body: CreateUserSchema,
responses: {
201: UserSchema,
400: ErrorSchema,
409: ErrorSchema, // Email already exists
},
}),
}),
},
{
pathPrefix: "/api",
strictStatusCodes: true,
},
)
export type Contract = typeof contract
export type Post = z.infer<typeof PostSchema>
export type CreatePost = z.infer<typeof CreatePostSchema>
export type User = z.infer<typeof UserSchema>
Next.js Route Handler
// app/api/posts/[...ts-rest]/route.ts — ts-rest Next.js handler
import { createNextHandler } from "@ts-rest/next"
import { contract } from "@/lib/api/contract"
import { db } from "@/lib/db"
import { posts } from "@/lib/db/schema"
import { eq, ilike, and, count, desc } from "drizzle-orm"
import { auth } from "@clerk/nextjs/server"
const handler = createNextHandler(
contract.posts,
{
list: async ({ query }) => {
const { page, pageSize, search, tag, published } = query
const offset = (page - 1) * pageSize
const where = []
if (search) where.push(ilike(posts.title, `%${search}%`))
if (tag) where.push(/* tag filter */ undefined as any)
if (published !== undefined) where.push(eq(posts.published, published))
const [result, totalResult] = await Promise.all([
db.query.posts.findMany({
where: where.length > 0 ? and(...where.filter(Boolean)) : undefined,
limit: pageSize,
offset,
orderBy: desc(posts.createdAt),
}),
db.select({ count: count() }).from(posts),
])
return {
status: 200,
body: {
posts: result as any,
total: totalResult[0]?.count ?? 0,
page,
pageSize,
},
}
},
get: async ({ params }) => {
const post = await db.query.posts.findFirst({
where: eq(posts.id, params.id),
})
if (!post) {
return { status: 404, body: { message: "Post not found" } }
}
return { status: 200, body: post as any }
},
getBySlug: async ({ params }) => {
const post = await db.query.posts.findFirst({
where: eq(posts.slug, params.slug),
})
if (!post) {
return { status: 404, body: { message: "Post not found" } }
}
return { status: 200, body: post as any }
},
create: async ({ body, request }) => {
const { userId } = await auth()
if (!userId) return { status: 401, body: { message: "Unauthorized" } }
const slug = body.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
const [post] = await db.insert(posts).values({
...body,
slug,
authorId: userId,
}).returning()
return { status: 201, body: post as any }
},
update: async ({ params, body }) => {
const { userId } = await auth()
if (!userId) return { status: 401, body: { message: "Unauthorized" } }
const existing = await db.query.posts.findFirst({ where: eq(posts.id, params.id) })
if (!existing) return { status: 404, body: { message: "Post not found" } }
if (existing.authorId !== userId) return { status: 403, body: { message: "Forbidden" } }
const [updated] = await db.update(posts).set(body).where(eq(posts.id, params.id)).returning()
return { status: 200, body: updated as any }
},
delete: async ({ params }) => {
const { userId } = await auth()
if (!userId) return { status: 401, body: { message: "Unauthorized" } }
const existing = await db.query.posts.findFirst({ where: eq(posts.id, params.id) })
if (!existing) return { status: 404, body: { message: "Post not found" } }
if (existing.authorId !== userId) return { status: 403, body: { message: "Forbidden" } }
await db.delete(posts).where(eq(posts.id, params.id))
return { status: 204, body: undefined }
},
},
{
jsonQuery: true,
responseValidation: process.env.NODE_ENV === "development",
errorHandler: (err: any, req: any) => {
console.error("[ts-rest error]", err)
},
},
)
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE }
React Query Client
// lib/api/hooks.ts — ts-rest React Query client
import { initQueryClient } from "@ts-rest/react-query"
import { contract } from "./contract"
export const apiClient = initQueryClient(contract, {
baseUrl: process.env.NEXT_PUBLIC_API_URL ?? "",
baseHeaders: {
"Content-Type": "application/json",
},
credentials: "include",
})
// Usage example:
// const { data, isLoading } = apiClient.posts.list.useQuery({
// queryKey: ["posts", { page: 1 }],
// query: { page: 1, pageSize: 10 },
// })
//
// const { mutateAsync } = apiClient.posts.create.useMutation()
// await mutateAsync({ body: { title, content, tags } })
// components/posts/PostList.tsx — using ts-rest React Query
"use client"
import { apiClient } from "@/lib/api/hooks"
import { useState } from "react"
export function PostList() {
const [page, setPage] = useState(1)
const { data, isLoading } = apiClient.posts.list.useQuery({
queryKey: ["posts", page],
query: { page, pageSize: 10, published: true },
})
const { mutateAsync: createPost, isPending } = apiClient.posts.create.useMutation({
onSuccess: () => {
// Invalidate post list after creation
},
})
if (isLoading) return <div>Loading...</div>
// TypeScript knows data.body.posts is Post[]
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{data?.body.total ?? 0} posts
</p>
{data?.body.posts.map(post => (
<div key={post.id} className="border rounded-xl p-4">
<h2 className="font-semibold">{post.title}</h2>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{post.excerpt}</p>
</div>
))}
</div>
)
}
For the tRPC alternative when a full-stack TypeScript application in a single Next.js repo (no separate API layer) with procedure-based calls, server-side context, middleware, and direct function invocation without HTTP is preferred — tRPC is ideal for monorepos where client and server share the same codebase, while ts-rest is better for REST APIs that need to be consumed by non-TypeScript clients, see the tRPC guide. For the Zodios alternative when a hand-authored type-safe HTTP client where you write the contract in TypeScript code against an existing API — Zodios and ts-rest solve the same problem but ts-rest has better server-side support and more integrations, see the Zodios guide. The Claude Skills 360 bundle includes ts-rest skill sets covering contracts, Next.js handlers, and React Query hooks. Start with the free tier to try contract-first API generation.