Fix: ts-rest Not Working — Contract Types Not Matching, Client Requests Failing, or Server Validation Errors
Part of: React & Frontend Errors
Quick Answer
How to fix ts-rest issues — contract definition, type-safe client and server setup, Zod validation, Next.js App Router integration, error handling, and OpenAPI generation.
The Problem
The ts-rest client sends a request but TypeScript shows a type error:
const result = await client.getPosts({ query: { limit: 10 } });
// Type error: Argument of type '{ query: { limit: number } }' is not assignableOr the server handler receives the wrong request shape:
// Server receives body as undefined even though the client sends dataOr Zod validation rejects valid input:
Contract validation failed: Expected string, received number at "id"Why This Happens
ts-rest is a library for building type-safe REST APIs where the contract (API schema) is shared between client and server:
- The contract is the source of truth — both client and server derive their types from the contract. If the contract defines
query: z.object({ limit: z.number() })but your client sends{ limit: "10" }(string), the types don’t match. The contract must accurately represent the HTTP layer (where query params are strings). - Path parameters, query, body, and headers have different serialization — URL query parameters are always strings in HTTP. ts-rest coerces them based on the contract’s Zod schema, but the schema must use
z.coerce.number()for query params that should be numbers. - Client and server must use the same contract import — if the client and server reference different copies of the contract (e.g., duplicated code instead of a shared package), type changes in one don’t propagate to the other.
- Next.js App Router needs the specific adapter — ts-rest has framework-specific server adapters. Using the Express adapter in a Next.js App Router project won’t work.
The root cause of most ts-rest confusion is the difference between transport layer types and application layer types. Plain Zod schemas describe your domain (“a number”), but HTTP transports always serialize values to strings. The contract sits in between and has to declare both — what arrives on the wire and what the handler should see. ts-rest’s z.coerce.* convention is the simplest bridge, but for richer cases you may need z.preprocess to handle nulls, comma-separated arrays, or date strings. Once you internalize that the contract is not your domain schema, the type errors start to make sense.
The second source of friction is monorepo wiring. ts-rest only delivers end-to-end safety if client and server import the exact same file. If your client lives in apps/web and your server lives in apps/api, the contract has to be a shared workspace package (packages/contracts). Otherwise, the two ends drift the moment someone updates a schema, and TypeScript can’t warn you because the duplicated copies are technically valid. See Fix: Drizzle ORM Not Working for the same monorepo discipline applied to schema sharing.
Version History: ts-rest from v1 to v3.x
ts-rest is younger than tRPC but iterated quickly. Knowing which release added what helps you read GitHub issues correctly:
- v1.x (Aug 2022) — first public release. Supported Express, Next.js Pages Router, and a fetch-based client. Contracts were simpler — no path params validation, no header validation.
- v2.x (Jan 2023) — added Fastify support,
@ts-rest/react-query(built against React Query v4), and improved type inference for nested responses. Path param validation became required. - v3.0 (Jul 2023) — major rewrite. Introduced
initContract(), structuredresponses, and the OpenAPI generation package (@ts-rest/open-api). This is the version most current tutorials assume. Earlier client code usinginitClientwith the old options object will fail to compile. - v3.30 (late 2023) — added Next.js App Router support via
@ts-rest/nextwithcreateNextHandlerandhandlerType: 'app-router'. Before this, App Router users had to write custom route adapters. - v3.40+ (early 2024) — introduced Server-Sent Events support, allowing contracts to declare streaming endpoints with
type: 'stream'. This is what unlocked AI chat use cases without falling back to manualResponsestreams. - v3.45 (mid 2024) — added RPC mode for the React Query integration, which removes the need to write status discriminators on every call. You can opt in per-contract.
- v3.50+ (late 2024 onward) — Zod 4 compatibility, Hono adapter, Vue Query bindings. Performance work on type inference reduced editor lag on large contracts.
How it compares to the alternatives matters when you choose a stack. tRPC v10 (Aug 2022) and tRPC v11 (Mar 2024) take the opposite approach — no contract, server functions auto-generate the client. tRPC is faster to set up but harder to expose to non-TypeScript consumers. Zodios (2022) was the original Zod-first REST library and inspired ts-rest, but development slowed and ts-rest absorbed most users by mid-2023. Hono RPC (built into Hono v3.6+) competes for fetch-based projects but doesn’t auto-generate OpenAPI. See Fix: tRPC Not Working and Fix: Hono Not Working for the alternatives’ own gotchas.
Fix 1: Define a Contract
npm install @ts-rest/core zod// contracts/index.ts — shared contract
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
// Define schemas
const PostSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
authorId: z.string(),
published: z.boolean(),
createdAt: z.string().datetime(),
});
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
published: z.boolean().optional().default(false),
});
const UpdatePostSchema = CreatePostSchema.partial();
// Define the API contract
export const contract = c.router({
getPosts: {
method: 'GET',
path: '/api/posts',
query: z.object({
page: z.coerce.number().default(1), // coerce: string → number
limit: z.coerce.number().default(20),
sort: z.enum(['newest', 'oldest']).default('newest'),
search: z.string().optional(),
}),
responses: {
200: z.object({
posts: z.array(PostSchema),
total: z.number(),
page: z.number(),
}),
},
},
getPost: {
method: 'GET',
path: '/api/posts/:id',
pathParams: z.object({
id: z.string(),
}),
responses: {
200: PostSchema,
404: z.object({ message: z.string() }),
},
},
createPost: {
method: 'POST',
path: '/api/posts',
body: CreatePostSchema,
responses: {
201: PostSchema,
400: z.object({ message: z.string(), errors: z.record(z.string()).optional() }),
},
},
updatePost: {
method: 'PATCH',
path: '/api/posts/:id',
pathParams: z.object({ id: z.string() }),
body: UpdatePostSchema,
responses: {
200: PostSchema,
404: z.object({ message: z.string() }),
},
},
deletePost: {
method: 'DELETE',
path: '/api/posts/:id',
pathParams: z.object({ id: z.string() }),
body: z.void(),
responses: {
204: z.void(),
404: z.object({ message: z.string() }),
},
},
});Fix 2: Type-Safe Client
npm install @ts-rest/core
# Or for React Query integration:
npm install @ts-rest/react-query @tanstack/react-query// lib/api-client.ts — vanilla client
import { initClient } from '@ts-rest/core';
import { contract } from '@/contracts';
export const apiClient = initClient(contract, {
baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
baseHeaders: {
'Content-Type': 'application/json',
},
});
// Usage
async function fetchPosts() {
const result = await apiClient.getPosts({
query: { page: 1, limit: 10, sort: 'newest' },
});
if (result.status === 200) {
return result.body; // Typed: { posts: Post[], total: number, page: number }
}
throw new Error('Failed to fetch posts');
}
async function createPost(data: { title: string; content: string }) {
const result = await apiClient.createPost({
body: data,
});
if (result.status === 201) {
return result.body; // Typed: Post
}
if (result.status === 400) {
throw new Error(result.body.message); // Typed: { message: string, errors?: Record<string, string> }
}
}
async function getPost(id: string) {
const result = await apiClient.getPost({
params: { id },
});
if (result.status === 200) return result.body;
if (result.status === 404) return null;
}Fix 3: React Query Integration
// lib/api-query.ts
import { initQueryClient } from '@ts-rest/react-query';
import { contract } from '@/contracts';
export const api = initQueryClient(contract, {
baseUrl: process.env.NEXT_PUBLIC_API_URL || '',
baseHeaders: {},
});// components/PostList.tsx
'use client';
import { api } from '@/lib/api-query';
function PostList() {
// Type-safe query — params and response are fully typed
const { data, isLoading, error } = api.getPosts.useQuery(
['posts'],
{ query: { page: 1, limit: 20, sort: 'newest' } },
);
// Type-safe mutation
const createMutation = api.createPost.useMutation();
async function handleCreate() {
const result = await createMutation.mutateAsync({
body: { title: 'New Post', content: 'Hello world' },
});
if (result.status === 201) {
console.log('Created:', result.body.id);
}
}
if (isLoading) return <div>Loading...</div>;
if (!data || data.status !== 200) return <div>Error</div>;
return (
<div>
{data.body.posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
<button onClick={handleCreate}>Create Post</button>
</div>
);
}Fix 4: Server Implementation (Next.js App Router)
npm install @ts-rest/next// app/api/posts/route.ts
import { createNextHandler } from '@ts-rest/next';
import { contract } from '@/contracts';
const handler = createNextHandler(contract, {
getPosts: async ({ query }) => {
// query is typed: { page: number, limit: number, sort: 'newest' | 'oldest', search?: string }
const { page, limit, sort, search } = query;
const posts = await db.query.posts.findMany({
where: search ? like(posts.title, `%${search}%`) : undefined,
orderBy: sort === 'newest' ? desc(posts.createdAt) : asc(posts.createdAt),
limit,
offset: (page - 1) * limit,
});
const total = await db.select({ count: count() }).from(postsTable);
return {
status: 200,
body: { posts, total: total[0].count, page },
};
},
createPost: async ({ body }) => {
// body is typed: { title: string, content: string, published?: boolean }
const post = await db.insert(postsTable).values({
...body,
id: crypto.randomUUID(),
authorId: 'current-user',
createdAt: new Date().toISOString(),
}).returning();
return { status: 201, body: post[0] };
},
}, {
// Response validation (optional — validates server responses match contract)
responseValidation: true,
// Error handler
handlerType: 'app-router',
});
export { handler as GET, handler as POST };// app/api/posts/[id]/route.ts
import { createNextHandler } from '@ts-rest/next';
import { contract } from '@/contracts';
const handler = createNextHandler(contract, {
getPost: async ({ params }) => {
const post = await db.query.posts.findFirst({
where: eq(posts.id, params.id),
});
if (!post) {
return { status: 404 as const, body: { message: 'Post not found' } };
}
return { status: 200 as const, body: post };
},
updatePost: async ({ params, body }) => {
const existing = await db.query.posts.findFirst({
where: eq(posts.id, params.id),
});
if (!existing) {
return { status: 404 as const, body: { message: 'Post not found' } };
}
const updated = await db.update(postsTable)
.set(body)
.where(eq(posts.id, params.id))
.returning();
return { status: 200 as const, body: updated[0] };
},
deletePost: async ({ params }) => {
const deleted = await db.delete(postsTable)
.where(eq(posts.id, params.id))
.returning();
if (deleted.length === 0) {
return { status: 404 as const, body: { message: 'Post not found' } };
}
return { status: 204 as const, body: undefined };
},
}, {
handlerType: 'app-router',
});
export { handler as GET, handler as PATCH, handler as DELETE };Fix 5: Authentication and Headers
// Contract with auth headers
export const contract = c.router({
getProfile: {
method: 'GET',
path: '/api/profile',
headers: z.object({
authorization: z.string(),
}),
responses: {
200: UserSchema,
401: z.object({ message: z.string() }),
},
},
});
// Client — pass auth header
const client = initClient(contract, {
baseUrl: '/api',
baseHeaders: {
authorization: `Bearer ${token}`,
},
});
// Or per-request headers
const result = await client.getProfile({
headers: { authorization: `Bearer ${freshToken}` },
});Fix 6: Generate OpenAPI Spec
npm install @ts-rest/open-api// scripts/generate-openapi.ts
import { generateOpenApi } from '@ts-rest/open-api';
import { contract } from '../contracts';
import fs from 'fs';
const openApiDocument = generateOpenApi(contract, {
info: {
title: 'My API',
version: '1.0.0',
description: 'API documentation generated from ts-rest contract',
},
servers: [
{ url: 'https://api.myapp.com', description: 'Production' },
{ url: 'http://localhost:3000', description: 'Development' },
],
});
fs.writeFileSync(
'openapi.json',
JSON.stringify(openApiDocument, null, 2),
);
console.log('OpenAPI spec generated at openapi.json');Still Not Working?
Type error on query params — “expected number, got string” — HTTP query parameters are always strings. Use z.coerce.number() instead of z.number() in query schemas. The coerce prefix tells Zod to convert the string to a number before validating. Same applies to booleans: z.coerce.boolean().
Client sends the request but server gets empty body — check that the Content-Type: application/json header is set. The base client should set this automatically, but custom fetch implementations might not. Also verify the contract’s body schema matches what you’re sending — ts-rest validates the body on the client side before sending.
Server returns 200 but client gets a type error on the response — result.status is a discriminated union. You must check the status before accessing result.body. TypeScript narrows the body type based on the status: if (result.status === 200) { result.body.posts } works, but result.body.posts without the check doesn’t because the body could be the 404 error shape.
React Query hooks not available — make sure you’re importing from @ts-rest/react-query and using initQueryClient, not initClient. The regular client doesn’t have .useQuery() or .useMutation() methods.
SSE / streaming endpoints return the wrong shape — Server-Sent Events arrived in v3.40+. If you upgraded from an older version, the type: 'stream' declaration on the contract route is silently ignored on older @ts-rest/next versions. Lock all @ts-rest/* packages to the same minor version. Stream endpoints also require returning an async generator from the handler — not a Response object.
OpenAPI spec is missing path parameter docs — @ts-rest/open-api only emits parameter documentation if the contract uses .describe() on the relevant Zod field. Add z.string().describe('Post UUID') on every path param and query field that should appear in the generated docs. The package does not infer descriptions from variable names.
Type errors after upgrading Zod to v4 — Zod 4 changed several inference signatures, especially for z.record and z.discriminatedUnion. Use @ts-rest/core 3.50 or later, which adjusted its type helpers to match. If you cannot upgrade ts-rest yet, pin Zod to the latest 3.x release until you can do both migrations together.
For related API and type-safety issues, see Fix: tRPC Not Working, Fix: Zod Validation Not Working, Fix: Hono Not Working, and Fix: Drizzle ORM Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error
How to fix Better Auth issues — server and client setup, email/password and OAuth providers, session management, middleware protection, database adapters, and plugin configuration.
Fix: next-safe-action Not Working — Action Not Executing, Validation Errors Missing, or Type Errors
How to fix next-safe-action issues — action client setup, Zod schema validation, useAction and useOptimisticAction hooks, middleware, error handling, and authorization patterns.
Fix: Auth.js (NextAuth) Not Working — Session Null, OAuth Callback Error, or CSRF Token Mismatch
How to fix Auth.js and NextAuth.js issues — OAuth provider setup, session handling in App Router and Pages Router, JWT vs database sessions, middleware protection, and credential provider configuration.
Fix: Payload CMS Not Working — Collections Not Loading, Auth Failing, or Admin Panel Blank
How to fix Payload CMS issues — collection and global config, access control, hooks, custom fields, REST and GraphQL APIs, Next.js integration, and database adapter setup.