Claude Code tRPC CLAUDE.md AI Coding TypeScript Zod 2026

CLAUDE.md for tRPC: Rules for Routers, Zod Validation, and End-to-End Type Safety (2026)

The Prompt Shelf ·

tRPC’s entire pitch is that a broken contract between client and server fails to compile, not fails at runtime. That guarantee only holds if the router, the input schemas, and the middleware chain are built the way tRPC expects — and an agent working from general TypeScript instincts instead of tRPC’s actual API will quietly work around the type safety instead of using it.

Out of the box, Claude Code or Cursor on a tRPC codebase will:

  • Write a fresh z.object({...}) inline for every procedure’s .input(), duplicating a schema that already exists as a Prisma model or a shared Zod type
  • Build a new procedure on publicProcedure by copying an existing one, silently dropping the auth check that protectedProcedure’s middleware was supposed to guarantee
  • Throw a plain Error or new Response(..., { status: 401 }) instead of a TRPCError, which loses the typed error.data.code the client is built to branch on
  • Put business logic directly inside a procedure resolver instead of a service function, so it can’t be reused by a second procedure or tested without spinning up the whole router
  • Skip superjson (or whatever transformer the project actually uses) in a new client setup, breaking Date and Map/Set serialization for that one endpoint

None of these are bugs in tRPC. They’re what happens when an agent applies generic REST or GraphQL instincts to a library whose entire value proposition depends on following its specific composition pattern.

Why tRPC Needs Its Own CLAUDE.md Section

tRPC sits in an unusual spot in most projects’ documentation. Framework guides for Next.js or NestJS cover routing, rendering, and module structure — the API layer gets a line or two, if that. But tRPC isn’t “a framework feature,” it’s the thing that makes the type boundary between frontend and backend load-bearing. Getting it wrong doesn’t produce a visible bug in review; it produces a contract that’s technically type-safe but doesn’t actually reflect what the server does.

The middleware pattern is easy to bypass without noticing. protectedProcedure exists specifically to narrow ctx.user from optional to guaranteed. If a new procedure is built on publicProcedure instead, nothing turns red — ctx.user is just undefined, and the first real request without a session throws at runtime instead of failing type-check.

Input validation is where type safety either holds or quietly stops. tRPC’s whole promise — the frontend’s inferred types come straight from the backend’s AppRouter — depends on every procedure actually validating its input with .input(schema). A procedure that skips it still type-checks; it just accepts anything at runtime while the client believes it’s constrained.

tRPC shows up differently depending on the stack around it, and each combination has its own default trap: Next.js App Router projects need the React Server Component caller pattern, not just the client hook; NestJS projects wire tRPC through a module instead of the vanilla adapter; standalone Node servers need the HTTP adapter directly. A CLAUDE.md that only says “we use tRPC” without stating which of these applies leaves an agent guessing at the integration layer every time.

Router and Procedure Structure

State the base pattern explicitly — this is the one place where copying it wrong propagates into every procedure written afterward.

## tRPC Setup

Initialization lives in `server/trpc.ts` and runs exactly once:

\`\`\`typescript
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import type { Context } from './context';

const t = initTRPC.context<Context>().create({
  transformer: superjson,
});

export const router = t.router;
export const publicProcedure = t.procedure;
\`\`\`

Never call \`initTRPC.create()\` a second time anywhere else in the codebase —
every router must import \`router\`/\`publicProcedure\` from this one file.

Routers stay thin. A router is a namespace, not a place for logic:

// server/routers/post.ts
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { z } from 'zod';
import { postCreateSchema } from '../schemas/post';
import { createPost, listPosts } from '../services/post-service';

export const postRouter = router({
  list: publicProcedure
    .input(z.object({ limit: z.number().min(1).max(100).default(20) }))
    .query(async ({ input, ctx }) => listPosts(ctx.db, input.limit)),

  create: protectedProcedure
    .input(postCreateSchema)
    .mutation(async ({ input, ctx }) => createPost(ctx.db, ctx.user.id, input)),
});

The rule worth stating outright: procedure resolvers call a service function and return its result — they don’t contain query logic, business rules, or side effects inline. That’s what makes postCreateSchema and createPost independently testable without a running tRPC server, and it’s the difference between a procedure Claude Code can safely refactor and one where the logic is welded to the routing layer.

Zod Schemas: One Source, Not One Per Procedure

This is the most common drift Claude Code introduces in a tRPC codebase, and it’s invisible in a single-file review because each inline schema looks reasonable on its own.

## Input Schemas

Zod schemas for procedure input live in \`server/schemas/\`, one file per
domain entity, and procedures import from there — never inline a fresh
\`z.object({...})\` for a shape that already has a schema.

\`\`\`typescript
// server/schemas/post.ts
import { z } from 'zod';

export const postCreateSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
  published: z.boolean().default(false),
});

export const postUpdateSchema = postCreateSchema.partial().extend({
  id: z.string().cuid(),
});
\`\`\`

If the project generates Zod schemas from Prisma models (via
\`zod-prisma-types\` or similar), import from the generated output instead of
hand-writing an equivalent — the generated schema stays in sync with schema
changes automatically; a hand-written one doesn't.

Derive, don’t duplicate, is the operative rule. postUpdateSchema reusing postCreateSchema.partial() instead of being written from scratch is a small example of the same principle: every additional independently-maintained schema is another place a field rename in the database has to be manually remembered.

Protected Procedures and Middleware

The auth boundary is the single highest-cost place to get tRPC wrong, because a missing check doesn’t fail loudly — it just serves the request.

## Auth Middleware

\`protectedProcedure\` is the base for anything that requires a session.
Never build an authenticated endpoint on \`publicProcedure\` and check
\`ctx.user\` manually inside the resolver — the whole point of the middleware
is that the type system enforces it instead of a runtime \`if\`.

\`\`\`typescript
// server/trpc.ts
import { TRPCError } from '@trpc/server';

const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      user: ctx.user, // now non-nullable for every procedure built on this
    },
  });
});

export const protectedProcedure = publicProcedure.use(isAuthed);
\`\`\`

Role-based checks compose the same way — a second middleware layered on
\`protectedProcedure\`, not a duplicated auth check inside a resolver:

\`\`\`typescript
const isAdmin = t.middleware(async ({ ctx, next }) => {
  if (ctx.user.role !== 'ADMIN') {
    throw new TRPCError({ code: 'FORBIDDEN' });
  }
  return next({ ctx });
});

export const adminProcedure = protectedProcedure.use(isAdmin);
\`\`\`

When Claude Code needs a new authenticated endpoint, the instruction is: start from protectedProcedure, not from copying the nearest existing procedure and adding a manual check. Copying propagates whatever base the nearest example happened to use, which is exactly how a publicProcedure-based endpoint with a hand-rolled if (!ctx.user) check ends up next to properly middleware-guarded ones in the same router.

Error Handling

## Errors

Procedures throw \`TRPCError\` with an explicit code — never a plain \`Error\`
or a manually constructed HTTP response.

\`\`\`typescript
import { TRPCError } from '@trpc/server';

export async function getPostOrThrow(db: Db, id: string) {
  const post = await db.post.findUnique({ where: { id } });
  if (!post) {
    throw new TRPCError({
      code: 'NOT_FOUND',
      message: \`Post \${id} not found\`,
    });
  }
  return post;
}
\`\`\`

Common codes and when they apply: \`BAD_REQUEST\` for input the Zod schema
already caught but needs a custom message, \`UNAUTHORIZED\` for no session,
\`FORBIDDEN\` for a session without permission, \`NOT_FOUND\` for a missing
resource, \`CONFLICT\` for a uniqueness violation. Do not use
\`INTERNAL_SERVER_ERROR\` for anything the caller could reasonably act on —
reserve it for genuinely unexpected failures.

A plain throw new Error("not found") still works — tRPC catches it and serializes something to the client — but it arrives as an opaque INTERNAL_SERVER_ERROR, and the client-side code loses the ability to branch on error.data.code to show “this post doesn’t exist” versus “something broke.” The distinction only exists if the server threw the right code in the first place.

Next.js App Router Integration

This is the integration surface most guides skip, and it’s the one that trips up agents most often on a T3-style stack, because there are two valid call patterns and mixing them up produces confusing hydration bugs.

## Next.js Integration

Server Components prefetch via the server-side caller and never import the
client hooks:

\`\`\`typescript
// app/posts/page.tsx (Server Component)
import { trpc } from '@/trpc/server';

export default async function PostsPage() {
  const posts = await trpc.post.list({ limit: 20 });
  return <PostList initialData={posts} />;
}
\`\`\`

Client Components that need live refetching, mutations, or optimistic
updates use the React Query-backed hooks — never call the server caller
from a \`'use client'\` file:

\`\`\`typescript
// components/post-list.tsx
'use client';
import { trpc } from '@/trpc/client';

export function PostList({ initialData }: { initialData: Post[] }) {
  const { data: posts } = trpc.post.list.useQuery(
    { limit: 20 },
    { initialData }
  );
  // ...
}
\`\`\`

If a Server Component only needs the data once and won't need client-side
refetching, call the router directly and skip React Query entirely —
wrapping every read in \`useQuery\` when nothing on the page ever refetches
adds a client bundle dependency for no benefit.

Stating both patterns and when each applies prevents the two most common mistakes: importing the client-side useQuery hook inside a Server Component (which doesn’t work — hooks require a client boundary), and reaching for the client hook inside a Server Component wrapper that then re-fetches data the server component already had.

Testing Procedures Without the Full Router

## Testing

Test service functions directly, not through the tRPC router — a procedure
resolver is a thin wrapper, and testing through the router means spinning up
context and middleware for logic that doesn't touch either.

\`\`\`typescript
// server/services/post-service.test.ts
import { describe, it, expect } from 'vitest';
import { createPost } from './post-service';
import { createMockDb } from '../test/mock-db';

describe('createPost', () => {
  it('rejects an empty title', async () => {
    const db = createMockDb();
    await expect(
      createPost(db, 'user-1', { title: '', body: 'x', published: false })
    ).rejects.toThrow();
  });
});
\`\`\`

For end-to-end coverage of the router itself — auth middleware, error codes,
the full request shape — use \`appRouter.createCaller(mockContext)\` to invoke
procedures directly without an HTTP layer:

\`\`\`typescript
const caller = appRouter.createCaller({ user: mockUser, db: testDb });
await expect(caller.post.create({ title: '', body: 'x', published: false }))
  .rejects.toMatchObject({ code: 'BAD_REQUEST' });
\`\`\`

The service-function-first testing rule matters for the same reason the procedures-call-services rule mattered above: it’s what makes tRPC’s routing layer thin enough that Claude Code can add or restructure a router without needing to also rewrite the test suite for logic that never changed.

CLAUDE.md vs AGENTS.md for tRPC Projects

AGENTS.md is the right home for the conventions above — router/service separation, the middleware chain, schema location, error codes. They’re tool-agnostic; the same rules apply whether Claude Code, Cursor, or Codex is generating the procedure. CLAUDE.md adds Claude Code-specific behavior on top: how much of the router to regenerate types for after a schema change, whether to run tsc --noEmit before considering a task done.

AGENTS.md       → router structure, middleware pattern, schema conventions, error codes
CLAUDE.md       → "run `tsc --noEmit` after touching server/routers/", verbosity preferences

If the project already has a Next.js or NestJS AGENTS.md, add a ## tRPC section to it instead of a separate file — the conventions here are compact enough to fit as one section, and keeping the API-layer rules next to the framework rules means an agent reads both in the same pass.

Complete CLAUDE.md Template

# CLAUDE.md

## Project Overview
[Next.js App Router / NestJS] with tRPC. TypeScript. Zod for validation.
[React Query / TanStack Query] on the client, superjson transformer.

## Commands
- Dev server: [framework-specific command]
- Type check: `tsc --noEmit`
- Run tests: [test command]

## tRPC Setup
Initialization lives in `server/trpc.ts`, called once. Import `router` and
`publicProcedure`/`protectedProcedure` from that file — never call
`initTRPC.create()` anywhere else.

## Router Structure
Procedure resolvers call a service function in `server/services/` and return
its result. No business logic, no direct DB queries inline in a resolver —
that's what makes services independently testable.

## Input Schemas
Zod schemas live in `server/schemas/`, one file per domain entity.
Procedures import from there. Never inline a `z.object({...})` for a shape
that already has a schema; derive variants with `.partial()`/`.extend()`
instead of rewriting them.

## Auth Middleware
Authenticated endpoints build on `protectedProcedure`, never `publicProcedure`
with a manual `ctx.user` check. Role checks compose as an additional
middleware layered on `protectedProcedure` (see `adminProcedure`).

## Errors
Throw `TRPCError` with an explicit code (`UNAUTHORIZED`, `FORBIDDEN`,
`NOT_FOUND`, `BAD_REQUEST`, `CONFLICT`). Reserve `INTERNAL_SERVER_ERROR` for
genuinely unexpected failures, not anything the caller could act on.

## Next.js Integration
Server Components prefetch via the server caller (`@/trpc/server`) and never
import client hooks. Client Components needing refetch/mutations/optimistic
updates use `@/trpc/client`'s React Query hooks, seeded with `initialData`
from the server fetch when available.

## Testing
Test service functions directly with mocked dependencies. For router-level
coverage (middleware, error codes), use `appRouter.createCaller()` instead of
spinning up an HTTP server.

## What to Avoid
- Calling `initTRPC.create()` more than once.
- Business logic inline in a procedure resolver instead of a service function.
- A fresh `z.object({...})` duplicating an existing schema.
- Building an authenticated procedure on `publicProcedure`.
- A plain `throw new Error(...)` instead of `TRPCError` with a code.
- Client hooks (`useQuery`) imported inside a Server Component.

How Different AI Tools Read These Files

Claude Code reads CLAUDE.md/AGENTS.md automatically, merging any it finds walking up the directory tree. The router/service split and the protectedProcedure rule are worth stating at the top of the file — they’re the two conventions most likely to be violated by an agent extending an existing router rather than starting one from scratch.

Cursor reads .cursorrules by default, with optional native AGENTS.md support. A .cursorrules → AGENTS.md symlink (or enabling native AGENTS.md support) avoids maintaining the tRPC section twice.

GitHub Copilot doesn’t read AGENTS.md natively — it reads .github/copilot-instructions.md. The auth middleware and error-code rules are worth copying there specifically, since they’re the highest-cost failure modes and Copilot won’t see them otherwise.

OpenAI Codex reads AGENTS.md natively with the same directory-tree resolution as Claude Code, so the template above applies unmodified.

Putting It Together

tRPC’s value only holds if the conventions around it are followed consistently — a router that’s mostly thin wrappers around services, schemas that live in one place instead of being re-derived per procedure, and an auth boundary that’s structural (protectedProcedure) rather than a scattered runtime check. None of these show up as a compile error when they’re violated, which is exactly why they need to be written down instead of assumed.

The two rules worth the most repetition, because they’re invisible in a diff and expensive when missed: never build an authenticated procedure on publicProcedure, and never let a Zod input schema duplicate one that already exists elsewhere in the codebase. Everything else — service extraction, error codes, the Next.js caller split — matters for maintainability, but those two are the ones that turn into a shipped auth gap or a schema that silently diverges from the database.

Start from the template above, note which framework tRPC is mounted on (Next.js App Router, NestJS, or a standalone adapter), and fill in the client integration section based on what’s actually consuming the router — a single Next.js frontend needs less than a stack where a mobile app and an admin dashboard both hit the same AppRouter.

Real-world examples of AI rules files that touch tRPC and adjacent TypeScript API layers are in our gallery — see Cal.com’s Do/Don’t AGENTS.md, Langfuse’s CLAUDE.md, and lobehub’s AGENTS.md for how production codebases document their own conventions.

FAQ

Does a tRPC project need its own CLAUDE.md section, separate from Next.js or NestJS?

Yes. tRPC’s rules — router structure, middleware-based procedure composition, Zod-first validation, error codes — apply the same regardless of which framework mounts it, so they’re worth documenting once rather than folded into a framework guide that only mentions tRPC in passing.

Why does Claude Code write duplicate Zod schemas for tRPC input and database types?

Without a stated rule, an agent treats each procedure’s .input() as a fresh task and writes a schema that happens to overlap with an existing Prisma model or shared type. The fix is documenting that input schemas live in one place and get derived (.partial(), .extend()), not rewritten per procedure.

Why does an agent use the outer context instead of the narrowed one inside protectedProcedure?

Because copying an existing procedure as a starting point inherits whatever base it happened to use. If that base is publicProcedure with a manual ctx.user check, the middleware’s structural guarantee — narrowing ctx.user to non-null — never applies, and the gap is invisible until a request without a session hits it.

How should error handling work in a tRPC procedure?

Throw TRPCError with an explicit code instead of a generic Error. tRPC maps the code to the correct HTTP status and gives the client a typed error.data.code to branch on — a plain thrown error collapses into an opaque INTERNAL_SERVER_ERROR on the client side.

Does tRPC still make sense next to Next.js Server Actions?

For mutations only ever called from the same Next.js app, Server Actions cover much of what tRPC used to be needed for. tRPC keeps its advantage when more than one client — a mobile app, a public API, a separate dashboard — consumes the same typed router.

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules