Build a Type-Safe tRPC API with Claude Code
Build a type-safe tRPC API with Claude Code: Next.js App Router, Zod, auth, cache invalidation, and review prompts.
What tRPC and Claude Code Solve Together
tRPC lets a TypeScript server expose typed procedures and lets the TypeScript client call them without a separate OpenAPI file or hand-written SDK. The important idea is simple: the server router becomes the source of truth, and the client imports the AppRouter type to know the valid inputs and outputs. For a team moving quickly, this removes a common source of drift where an API response changes but the front-end type definition does not.
Claude Code is useful because the hard part is not typing initTRPC. The hard part is keeping the router structure, Zod validation, authorization, error handling, React Query invalidation, and project conventions consistent as the codebase grows. When Claude Code can read the existing repository, it can propose procedures that fit the local folder layout, reuse the existing auth context, and add review notes for the parts that type safety alone cannot protect.
This article uses a Next.js App Router setup with a small project-management API. The storage layer is an in-memory projectStore so the example stays copyable, but the boundary is intentionally similar to a Prisma, Drizzle, Supabase, or REST-backed repository. Before shipping this pattern, replace the demo session lookup with your real authentication, use persistent storage, and add tests around authorization.
Practical Use Cases
tRPC works best when the API and UI live in the same TypeScript system and change together. It is less attractive when public third-party clients need a stable HTTP contract, but it is excellent for internal product surfaces where front-end and server changes move in one pull request.
| Use case | Why tRPC helps | What Claude Code can generate | Main risk |
|---|---|---|---|
| Admin CRUD screens | List, create, update, and delete inputs stay typed end to end | Routers, Zod schemas, mutations, invalidation calls | Hiding a button in the UI instead of enforcing authorization |
| Internal tools | Small workflow changes can ship without maintaining a separate SDK | Procedures based on existing DB models and permissions | Putting every dependency into context |
| Form submission | Runtime validation catches malformed JSON and direct API calls | Form schemas, error messages, retry-safe mutations | Trusting TypeScript types without Zod validation |
| Thin BFF layer | The UI can receive exactly the shape it needs | Response mapping from external services | Cache and invalidation rules becoming unclear |
The mental model is a short pipeline: React calls a typed client, the typed client reaches the router, the procedure checks auth, Zod validates runtime input, and the store performs the business operation.
flowchart LR
UI["React component"]
Client["tRPC React client"]
Router["AppRouter"]
Procedure["protectedProcedure / adminProcedure"]
Zod["Zod validation"]
Store["DB or store"]
Review["Claude Code review"]
UI --> Client --> Router --> Procedure --> Zod --> Store
Router --> Review
Review --> Procedure
Install and Choose a File Layout
Install the core packages first. If the project already uses React Query or Zod, align versions before asking Claude Code to generate the router. Mixed versions are a common reason for confusing type errors.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
This guide assumes the following layout. It keeps server-only code under src/server, the HTTP adapter under the App Router API route, and the React client provider under src/trpc.
src/
app/
api/trpc/[trpc]/route.ts
projects/project-list.tsx
server/
db.ts
trpc.ts
routers/
_app.ts
project.ts
trpc/
client.tsx
When prompting Claude Code, give it this boundary explicitly: server code must not import client components, client components must not import the store directly, and shared types should flow through the tRPC router rather than a parallel hand-maintained DTO file. That one instruction prevents many messy first drafts.
Create Context, Protected Procedures, and Admin Procedures
The context is request-scoped data passed into every procedure. Keep it small. A session, a tenant or team identifier, and a database handle are normal. A large grab bag of UI state, feature flags, external clients, and temporary values makes procedures hard to test and review.
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
type Role = "admin" | "member";
type Session = {
userId: string;
teamId: string;
role: Role;
};
export type Context = {
session: Session | null;
};
export async function createContext({
headers,
}: {
headers: Headers;
}): Promise<Context> {
// Demo only: replace this with auth.js, Clerk, Supabase Auth, or your own session lookup.
const roleHeader = headers.get("x-user-role");
const role: Role =
roleHeader === "admin"
? "admin"
: roleHeader === "member"
? "member"
: process.env.NODE_ENV === "production"
? "member"
: "admin";
return {
session: {
userId: headers.get("x-user-id") ?? "demo-user",
teamId: headers.get("x-team-id") ?? "demo-team",
role,
},
};
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Login is required.",
});
}
return next({
ctx: {
session: ctx.session,
},
});
});
export const protectedProcedure = t.procedure.use(requireUser);
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.session.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin role is required.",
});
}
return next();
});
The split between protectedProcedure and adminProcedure is more than style. It creates an obvious review target. If a destructive mutation uses protectedProcedure, Claude Code and human reviewers can challenge it immediately. If every procedure hand-rolls if (ctx.session.role !== ...), mistakes hide inside business logic.
Add a Router with Zod Runtime Validation
TypeScript protects the developer experience at build time. It does not validate arbitrary JSON arriving at runtime. Zod fills that gap by checking input after the request arrives and before the business operation runs. That is why the schema belongs at the procedure boundary.
// src/server/db.ts
export type ProjectStatus = "todo" | "doing" | "done";
export type Project = {
id: string;
teamId: string;
title: string;
ownerEmail: string;
status: ProjectStatus;
dueDate?: string;
createdAt: string;
};
const projects = new Map<string, Project>();
export const projectStore = {
list(input: {
teamId: string;
status?: ProjectStatus;
query?: string;
limit: number;
}) {
return [...projects.values()]
.filter((project) => project.teamId === input.teamId)
.filter((project) => !input.status || project.status === input.status)
.filter((project) => {
if (!input.query) return true;
return project.title.toLowerCase().includes(input.query.toLowerCase());
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, input.limit);
},
findById(id: string) {
return projects.get(id) ?? null;
},
create(input: Omit<Project, "id" | "createdAt">) {
const project: Project = {
...input,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
};
projects.set(project.id, project);
return project;
},
updateStatus(id: string, status: ProjectStatus) {
const project = projects.get(id);
if (!project) return null;
const nextProject = { ...project, status };
projects.set(id, nextProject);
return nextProject;
},
remove(id: string) {
return projects.delete(id);
},
};
// src/server/routers/project.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { projectStore } from "../db";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "../trpc";
const projectStatus = z.enum(["todo", "doing", "done"]);
const listProjectsInput = z
.object({
status: projectStatus.optional(),
query: z.string().trim().max(60).optional(),
limit: z.number().int().min(1).max(50).default(20),
})
.default({ limit: 20 });
const createProjectInput = z.object({
title: z.string().trim().min(2).max(80),
ownerEmail: z.string().email(),
dueDate: z.string().datetime().optional(),
});
export const projectRouter = createTRPCRouter({
list: protectedProcedure.input(listProjectsInput).query(({ ctx, input }) => {
return projectStore.list({
teamId: ctx.session.teamId,
status: input.status,
query: input.query,
limit: input.limit,
});
}),
create: adminProcedure.input(createProjectInput).mutation(({ ctx, input }) => {
return projectStore.create({
teamId: ctx.session.teamId,
title: input.title,
ownerEmail: input.ownerEmail,
dueDate: input.dueDate,
status: "todo",
});
}),
updateStatus: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
status: projectStatus,
}),
)
.mutation(({ ctx, input }) => {
const project = projectStore.findById(input.id);
if (!project || project.teamId !== ctx.session.teamId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return projectStore.updateStatus(input.id, input.status);
}),
delete: adminProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(({ ctx, input }) => {
const project = projectStore.findById(input.id);
if (!project || project.teamId !== ctx.session.teamId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
projectStore.remove(input.id);
return { ok: true };
}),
});
Notice the tenant check in updateStatus and delete. A UUID input type is not authorization. The procedure must verify that the record belongs to the current team before updating it. This is exactly the kind of mistake Claude Code should be asked to find, because the TypeScript compiler will not complain about missing business rules.
Connect the Router to Next.js App Router
Now compose the application router and expose it through a Route Handler.
// src/server/routers/_app.ts
import { createTRPCRouter } from "../trpc";
import { projectRouter } from "./project";
export const appRouter = createTRPCRouter({
project: projectRouter,
});
export type AppRouter = typeof appRouter;
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };
At this point the server contract is the AppRouter type. Do not create another manually maintained client type file unless you have a very specific boundary that needs it. Parallel types are how drift returns.
Call Procedures from React
The React side uses @trpc/react-query, so each query and mutation also gets React Query state such as loading, error, and invalidation helpers. The example below is intentionally plain. In a production UI, wrap errors in your design system and move form validation closer to the fields.
// src/trpc/client.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState, type ReactNode } from "react";
import superjson from "superjson";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();
function getBaseUrl() {
if (typeof window !== "undefined") return "";
return process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
}
export function TRPCProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
// src/app/projects/project-list.tsx
"use client";
import { useState } from "react";
import { trpc } from "@/trpc/client";
export function ProjectList() {
const utils = trpc.useUtils();
const [title, setTitle] = useState("");
const [ownerEmail, setOwnerEmail] = useState("owner@example.com");
const projects = trpc.project.list.useQuery({ limit: 20 });
const createProject = trpc.project.create.useMutation({
onSuccess: async () => {
setTitle("");
await utils.project.list.invalidate();
},
});
const updateStatus = trpc.project.updateStatus.useMutation({
onSuccess: async () => {
await utils.project.list.invalidate();
},
});
return (
<section>
<form
onSubmit={(event) => {
event.preventDefault();
createProject.mutate({ title, ownerEmail });
}}
>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Project title"
/>
<input
value={ownerEmail}
onChange={(event) => setOwnerEmail(event.target.value)}
placeholder="owner@example.com"
/>
<button type="submit" disabled={createProject.isPending}>
Add project
</button>
</form>
{projects.isLoading ? <p>Loading...</p> : null}
{projects.error ? <p>{projects.error.message}</p> : null}
<ul>
{projects.data?.map((project) => (
<li key={project.id}>
<strong>{project.title}</strong> {project.status}
<button
type="button"
onClick={() =>
updateStatus.mutate({ id: project.id, status: "done" })
}
>
Mark done
</button>
</li>
))}
</ul>
</section>
);
}
If you try to pass status: "finished", the client code fails type checking. If a raw HTTP caller sends that value anyway, Zod rejects it at runtime. That double layer is the practical value of using tRPC and Zod together.
Give Claude Code a Critical Review Prompt
A vague prompt such as “review this API” tends to produce vague praise. Use a checklist that names the risks you actually care about.
Review this Next.js App Router + tRPC implementation.
Check:
1. publicProcedure, protectedProcedure, and adminProcedure are used correctly.
2. Every external input has Zod runtime validation.
3. teamId and userId tenant boundaries are enforced before reads and writes.
4. Mutations invalidate every affected query.
5. Routers are split by domain and are not turning into one large file.
6. Context contains only request-scoped dependencies.
7. No secret fields are returned to the client.
Return severity, file, problem, fix, and recommended tests in a table.
This prompt is also useful in a pull request template or CLAUDE.md. For sensitive authorization changes, ask Claude Code for a diff first and apply the patch only after a human review. The model can catch patterns quickly, but the team still owns the business rules.
Failure Modes to Watch
The first failure mode is a bloated context. It starts innocently: add the database, then a billing client, then feature flags, then a UI-specific value. Soon every procedure appears to depend on everything. Keep context small and push business operations into explicit functions or repositories.
The second failure mode is authorization by UI. A hidden button is not a permission check. Destructive operations such as delete, invite, export, and billing.updatePlan should use admin or owner procedures on the server, and tests should call the procedure as a lower-privilege user.
The third failure mode is trusting static types without runtime validation. TypeScript does not validate JSON sent by a browser, a script, or a compromised client. Use Zod for email formats, string length, enum values, number ranges, and optional fields. Put business boundaries into the schema when possible.
The fourth failure mode is router sprawl. A single appRouter file feels fast for the first week and painful after the third feature. Split routers by domain, keep shared procedures in trpc.ts, and let Claude Code generate new routers in the same pattern.
The fifth failure mode is cache confusion. A mutation often affects a list, a detail view, a dashboard count, and sometimes a notification badge. Ask Claude Code to list the affected queries before implementation. That habit prevents stale UI bugs that look random to users.
Official References and Next Steps
Use the official tRPC docs for routers, procedures, and React Query integration. Use the Zod documentation for runtime validation and the Next.js Route Handlers documentation for App Router details.
For related ClaudeCodeLab material, see the Claude Code Zod validation guide and Claude Code TypeScript tips. If your team wants to turn an existing internal tool or admin surface into a safer tRPC architecture, Claude Code consultation and training can cover router design, authorization review, and practical prompts using your own repository.
What Happened When We Tried It
In a small admin-screen prototype, the biggest quality improvement came from creating protectedProcedure and adminProcedure before generating any CRUD router. Claude Code then had a clear rule to enforce and repeatedly flagged mutations that deserved admin-only access. Keeping Zod schemas near the procedures also made changes easier to review because form fields, API inputs, and validation constraints moved together. The main downside was test friction when the context grew too convenient, so the final version kept context narrow and moved store operations into separate functions.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.