← All posts
·12 min read

Claude Code with tRPC: Routers, Procedures, Type Safety

Claude CodetRPCAPIWorkflow
Claude Code with tRPC: Routers, Procedures, Type Safety

Why tRPC rewards strict Claude Code rules

tRPC sells one promise: the function you call on the client is the same function defined on the server, same types, no codegen, no schema file in between. That promise only holds when the project enforces a small number of patterns. Break any one and inference collapses silently, leaving a client that compiles but calls procedures with shapes the server cannot parse.

The failure modes are subtle. A procedure without .input(z.object(...)) accepts unknown, so the client gets void for the input type and TypeScript stops helping. A router exported as a value instead of type AppRouter = typeof appRouter works at runtime but breaks declaration emit. A middleware that returns next() without an augmented context strips type information from downstream procedures. None of these throw at edit time. They all ship.

Claude Code generates excellent tRPC routers when the project tells it which patterns are mandatory. Without rules, Claude defaults to older v10 syntax with looser input types. If you have not configured Claude Code yet, the Claude Code setup guide covers the install before any of this applies.

The tRPC CLAUDE.md template

Before any router is written, the CLAUDE.md needs to encode the architectural decisions that downstream code depends on: version, router structure, input validation library, middleware patterns, error handling rules, and client transport.

# tRPC project rules

## Stack
- Node 20.x, TypeScript 5.x (strict, noUncheckedIndexedAccess on)
- tRPC v11 (@trpc/server, @trpc/client, @trpc/react-query)
- Input validation: Zod v3 (every procedure input)
- Database: PostgreSQL via Drizzle ORM (singleton in server/db.ts)
- Client cache: @tanstack/react-query v5, superjson transformer

## Project structure
- Root router: server/api/root.ts (combines sub-routers)
- Sub-routers: server/api/routers/{domain}.ts (one per domain)
- Procedure builder: server/api/trpc.ts (publicProcedure, protectedProcedure)
- Context: server/api/context.ts (request-scoped, holds db + session)
- Client provider: app/_trpc/Provider.tsx (React Query + tRPC)
- Server caller: server/api/caller.ts (for RSC and server actions)

## Hard rules
- NEVER write a procedure without .input(z.object(...)) (or z.void())
- NEVER use ctx.session?.user without going through protectedProcedure
- NEVER throw a raw Error from a procedure (always TRPCError with code)
- NEVER export the router as a value re-export (use `type AppRouter = typeof appRouter`)
- ALWAYS return next({ ctx: {...} }) from middleware that augments context
- ALWAYS use the procedure builder, not t.procedure directly in router files
- ALWAYS infer client input/output types from AppRouter, never duplicate them

Three lines do most of the work. The Zod input rule is the most valuable: without it Claude will sometimes skip .input(), sometimes use .input<MyInput>(), sometimes cast the parse result, and each variant breaks inference differently. The TRPCError rule prevents the most common error-handling drift: a procedure that throws new Error("not found") gets wrapped in INTERNAL_SERVER_ERROR with the raw message exposed to the client, where a TRPCError({ code: "NOT_FOUND" }) produces a proper 404 with a typed error code. The type AppRouter export is the one Claude gets wrong most often: exporting export const appRouter = router({...}) then export type { appRouter } from a barrel file produces a value re-export, not a type re-export, and consumers get any. The correct line is export type AppRouter = typeof appRouter, on its own.

Router and procedure patterns

The router is the single source of truth in tRPC. Every procedure, every input, every output, every wrapping middleware lives in the router definition. The procedure builder lives in server/api/trpc.ts and every line matters:

import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import { ZodError } from 'zod'
import type { Context } from './context'

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter: ({ shape, error }) => ({
    ...shape,
    data: {
      ...shape.data,
      zodError:
        error.cause instanceof ZodError ? error.cause.flatten() : null,
    },
  }),
})

export const router = t.router
export const publicProcedure = t.procedure

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
  return next({
    ctx: { session: { ...ctx.session, user: ctx.session.user } },
  })
})

export const protectedProcedure = t.procedure.use(isAuthed)

Two things here are easy for Claude to skip and expensive to fix later. The superjson transformer lets tRPC carry Date, Map, Set, BigInt, and other non-JSON values across the wire. Without it, { createdAt: new Date() } arrives on the client as a string. The errorFormatter with ZodError handling exposes error.data.zodError.fieldErrors to the client (a structured object keyed by field name) instead of a generic BAD_REQUEST.

A typical sub-router:

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { posts } from '@/server/db/schema'
import { eq } from 'drizzle-orm'

export const postsRouter = router({
  list: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(20),
      cursor: z.string().optional(),
    }))
    .query(async ({ ctx, input }) => {
      const rows = await ctx.db
        .select().from(posts)
        .limit(input.limit + 1).orderBy(posts.createdAt)
      const nextCursor = rows.length > input.limit ? rows.pop()?.id : undefined
      return { items: rows, nextCursor }
    }),

  byId: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      const [post] = await ctx.db
        .select().from(posts).where(eq(posts.id, input.id)).limit(1)
      if (!post) {
        throw new TRPCError({ code: 'NOT_FOUND', message: `Post ${input.id} not found` })
      }
      return post
    }),

  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      content: z.string().min(1).max(50000),
    }))
    .mutation(async ({ ctx, input }) => {
      const [created] = await ctx.db.insert(posts).values({
        title: input.title,
        content: input.content,
        authorId: ctx.session.user.id,
      }).returning()
      return created
    }),
})

Four details compound into end-to-end safety. .input(z.object({...})) infers the input type from the schema, so the handler receives input: { limit: number; cursor: string | undefined }. .query for reads, .mutation for writes, with no exceptions: tRPC enforces this through React Query (queries are cached and refetched, mutations are not). TRPCError with a structured code translates to a proper HTTP status and a typed client error. protectedProcedure runs the auth middleware so ctx.session.user.id inside the handler is non-null and fully typed. The router is composed at the root:

import { router } from './trpc'
import { postsRouter } from './routers/posts'
import { usersRouter } from './routers/users'

export const appRouter = router({
  posts: postsRouter,
  users: usersRouter,
})

export type AppRouter = typeof appRouter

The type AppRouter = typeof appRouter line is the contract. Every client, every server caller, every consumer imports this type. If it breaks (a procedure with the wrong shape, a middleware that strips context, an unparseable Zod default), every consumer breaks. Catching it in one place is the whole point of tRPC. The Claude Code TypeScript guide covers the strict-mode configuration that makes inference reliable.

Zod inputs, middleware, and TRPCError

Zod is the parser, the validator, and the type generator. tRPC integrates with it so deeply the two libraries are effectively one layer. Every input flows through Zod, every parse result becomes the typed handler input, every Zod error becomes a structured client error. The CLAUDE.md section that governs inputs:

## Zod input rules
- Use z.object({...}) for procedures with named fields, z.void() for no input
- Never use z.any() or z.unknown() in an input schema
- Never type the input via .input<MyType>() (no generic type form)
- Strings: always z.string().min(1) for required, .max(N) for bounded
- IDs: z.string().uuid() or z.string().cuid2()
- Enums: z.enum([...]) for finite sets, never z.string() with a comment
- Shared schemas live in server/api/schemas/, exported with z.infer

A shared schema:

import { z } from 'zod'

export const createPostInput = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1).max(50000),
  tags: z.array(z.string().min(1).max(50)).max(10).default([]),
})

export type CreatePostInput = z.infer<typeof createPostInput>

The procedure imports the schema and the consumer (form, client call, server action) imports the inferred type. For deeper Zod patterns including refinements, discriminated unions, and async validation, the Claude Code Zod guide covers the validation patterns that pair with tRPC routers.

Middleware is where cross-cutting concerns live. It runs before the handler, can augment the context, can short-circuit with an error, and can wrap the handler with timing or logging:

const logger = t.middleware(async ({ path, type, next }) => {
  const start = Date.now()
  const result = await next()
  const duration = Date.now() - start
  console.log(`${type} ${path} ${result.ok ? 'OK' : 'ERR'} ${duration}ms`)
  return result
})

const isAdmin = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
  if (ctx.session.user.role !== 'admin') {
    throw new TRPCError({ code: 'FORBIDDEN' })
  }
  return next({ ctx: { session: ctx.session } })
})

export const loggedProcedure = t.procedure.use(logger)
export const adminProcedure = t.procedure.use(logger).use(isAdmin)

Chain order is execution order: t.procedure.use(logger).use(isAdmin) logs every call then runs the admin check. Claude sometimes inverts this, which means unauthorized calls go unlogged. The rule "logging first, auth second, business middleware last" prevents the inversion. The next({ ctx: {...} }) return is non-negotiable: a middleware that returns next() without the ctx argument strips type information from downstream procedures. The session is present at runtime but TypeScript thinks ctx.session is Session | undefined, defeating the whole point of protectedProcedure.

For errors, throw a TRPCError with a structured code, optionally with message and cause, and the client receives a typed error including Zod field errors. The codes worth knowing: BAD_REQUEST (400, mostly auto-thrown by Zod), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), PRECONDITION_FAILED (412), UNPROCESSABLE_CONTENT (422), TOO_MANY_REQUESTS (429), INTERNAL_SERVER_ERROR (500, avoid throwing manually). The cause parameter wraps a caught error from a database driver or external API without exposing internals to the client:

try {
  await ctx.db.insert(users).values({ email: input.email })
} catch (e) {
  if (isUniqueConstraintError(e)) {
    throw new TRPCError({
      code: 'CONFLICT',
      message: 'Email already registered',
      cause: e,
    })
  }
  throw e
}

The cause is logged server-side but not exposed to the client. For the broader Next.js wiring that hosts tRPC routes, the Claude Code Next.js guide covers the App Router setup that pairs with the patterns here.

Client setup: React Query and the server caller

The client side is where type safety pays off most visibly. Every procedure call gets the input type from the schema, the output type from the handler, the error type from TRPCError, and the React Query cache key from the procedure path, with no duplicate types. The provider wires React Query and tRPC together:

'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { createTRPCReact } from '@trpc/react-query'
import superjson from 'superjson'
import { useState } from 'react'
import type { AppRouter } from '@/server/api/root'

export const trpc = createTRPCReact<AppRouter>()

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: { queries: { staleTime: 30_000, refetchOnWindowFocus: false } },
  }))
  const [trpcClient] = useState(() => trpc.createClient({
    links: [httpBatchLink({ url: '/api/trpc', transformer: superjson })],
  }))
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  )
}

The createTRPCReact<AppRouter>() call is where the type lineage starts. Every hook off the trpc export carries the full router type. A consumer writes trpc.posts.list.useQuery(...) and gets autocomplete for input, typing for output, and the right cache key automatically. A component that consumes the router:

'use client'

import { trpc } from '@/app/_trpc/Provider'

export function PostList() {
  const { data, isLoading, error } = trpc.posts.list.useQuery({ limit: 20 })

  if (isLoading) return <div>Loading</div>
  if (error) {
    if (error.data?.code === 'UNAUTHORIZED') return <div>Please sign in</div>
    return <div>Error: {error.message}</div>
  }
  if (!data) return null

  return (
    <ul>{data.items.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
  )
}

export function CreatePost() {
  const utils = trpc.useUtils()
  const create = trpc.posts.create.useMutation({
    onSuccess: () => utils.posts.list.invalidate(),
  })

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const f = new FormData(e.currentTarget)
      create.mutate({
        title: f.get('title') as string,
        content: f.get('content') as string,
      })
    }}>
      <input name="title" required />
      <textarea name="content" required />
      <button disabled={create.isPending}>
        {create.isPending ? 'Creating' : 'Create'}
      </button>
      {create.error?.data?.zodError?.fieldErrors.title && (
        <p>{create.error.data.zodError.fieldErrors.title[0]}</p>
      )}
    </form>
  )
}

The utils.posts.list.invalidate() after a mutation keeps the React Query cache fresh. tRPC exposes a typed utils object that mirrors the router shape, so cache invalidation is type-checked against actual procedure paths. A typo fails at edit time. For broader React patterns including state management and Suspense, the Claude Code React guide covers the component patterns that pair with tRPC client hooks.

The server-side caller is for server-only code that needs to call procedures: RSCs, server actions, scheduled jobs, internal services. It skips HTTP and calls the handler directly with full type safety:

import { appRouter } from './root'
import { createContext } from './context'

export async function createCaller(req: Request) {
  const ctx = await createContext({ req })
  return appRouter.createCaller(ctx)
}

Used in an RSC:

import { createCaller } from '@/server/api/caller'
import { headers } from 'next/headers'

export default async function PostsPage() {
  const caller = await createCaller(
    new Request('http://internal', { headers: await headers() })
  )
  const { items } = await caller.posts.list({ limit: 20 })
  return (
    <ul>{items.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
  )
}

The server caller returns the procedure result directly, with no HTTP round trip and no JSON serialization. It is faster than the client and useful for initial render data in RSCs, but bypasses React Query so there is no client-side cache. The right pattern is server caller for initial data, client hooks for interactive updates, with React Query hydrated from the server result if needed. The Claude Code Drizzle guide covers the ORM schema and query patterns that pair with the handlers shown here.

Hard rules and putting it together

The CLAUDE.md template earlier in this guide encodes the rules. The verification checklist is what you run before merging Claude-generated tRPC code.

Every procedure has a Zod input schema. Grep router files for .query( and .mutation( and check every match is preceded by .input(z.. A procedure without .input() accepts unknown, which produces a void input type on the client and silent runtime failures.

TRPCError on every thrown error. Grep handlers for throw new Error(. Each match is a candidate for replacement with a typed TRPCError. The exception is errors from upstream libraries (Drizzle, Zod, fetch) that should be caught and re-thrown with a cause: parameter. Raw Error thrown from a procedure becomes INTERNAL_SERVER_ERROR with the raw message exposed to the client, a privacy regression as well as a UX one.

Middleware returns next({ ctx }) not next(). Grep middleware for return next() with no arguments. Each match strips type information from downstream procedures. The middleware should always pass context explicitly, even if unchanged, because that is what tRPC's type narrowing keys off.

AppRouter exported as a type, not a value. Grep the root router file for export type AppRouter = typeof appRouter. If the export is export { appRouter } followed by export type { appRouter as AppRouter }, consumers get any and the entire type chain collapses.

No as any or as unknown in router files. These casts are usually how Claude silences a type error it does not understand, and each one is a potential silent bug. Fail review if a cast was not present before the diff.

Procedure path matches the router structure. A procedure called as trpc.posts.list.useQuery(...) only works if appRouter.posts.list exists with the right shape. Run tsc --noEmit after any router rename and fix every reported call site before merging.

A tRPC project configured along these lines produces Claude Code output that preserves end-to-end type safety from the database row to the React component. Every procedure has a Zod input, every error is a structured TRPCError, every middleware passes the typed context forward, and every client hook gets full inference from the router definition. Without CLAUDE.md, the same prompts drift from end-to-end safety over time: a procedure without .input() here, a raw Error throw there, a middleware that drops context elsewhere.

Start with the template at the top of this guide. Wire up the procedure builder with superjson and the Zod error formatter. Add protectedProcedure and loggedProcedure as your default middleware-augmented procedures. Run the verification checklist before merging the first PR. Claudify includes a tRPC-specific CLAUDE.md template as part of the Claude Code workflow kit, with router patterns, Zod schemas, middleware definitions, and React Query wiring pre-configured for TypeScript projects.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir