Fix: ts-rest Not Working — Contract Types Not Matching, Client Requests Failing, or Server Validation 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.
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.
For related API and type-safety issues, see Fix: tRPC Not Working and Fix: Zod Validation 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.