Use Cases (Updated: 6/2/2026)

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.

Build a Type-Safe tRPC API with Claude Code

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 caseWhy tRPC helpsWhat Claude Code can generateMain risk
Admin CRUD screensList, create, update, and delete inputs stay typed end to endRouters, Zod schemas, mutations, invalidation callsHiding a button in the UI instead of enforcing authorization
Internal toolsSmall workflow changes can ship without maintaining a separate SDKProcedures based on existing DB models and permissionsPutting every dependency into context
Form submissionRuntime validation catches malformed JSON and direct API callsForm schemas, error messages, retry-safe mutationsTrusting TypeScript types without Zod validation
Thin BFF layerThe UI can receive exactly the shape it needsResponse mapping from external servicesCache 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.

#Claude Code #tRPC #TypeScript #API #type safety
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.