Skip to content

Fix: ts-rest Not Working — Contract Types Not Matching, Client Requests Failing, or Server Validation Errors

FixDevs ·

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 assignable

Or the server handler receives the wrong request shape:

// Server receives body as undefined even though the client sends data

Or 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 responseresult.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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles