Claude Code for ts-rest: Type-Safe API Contracts Without Code Generation — Claude Skills 360 Blog
Blog / Backend / Claude Code for ts-rest: Type-Safe API Contracts Without Code Generation
Backend

Claude Code for ts-rest: Type-Safe API Contracts Without Code Generation

Published: June 1, 2027
Read time: 6 min read
By: Claude Skills 360

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.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free