tRPC APIs skill for Claude Code

Typesafe procedures with auth that narrows context. Claude builds tRPC routers with a cheap typed context, protected and role-based base procedures that narrow ctx.user, Zod validation on input and output, and per-object ownership checks inside each procedure. The client imports only the router type, uses the v11 TanStack Query integration for queries and invalidation, and createCaller tests prove that the unauthorized and not-found paths hold.

  • nametrpc-apis
  • size280 lines · ≈2,600 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds end-to-end typesafe APIs with tRPC v11 - initTRPC with typed context, routers and procedures, Zod or Standard Schema input and output validation, auth middleware that narrows context, TRPCError codes, fetch and Express adapters, httpBatchLink and streaming links, and the TanStack React Query integration with queryOptions, invalidation and server-side prefetch. Use when adding tRPC to a TypeScript monorepo or Next.js app, writing procedures or middleware, migrating from tRPC v10 hooks, or fixing client types that resolve to any.

Try asking

  • Add a tRPC router for orders with auth middleware to our Next.js app
  • All our tRPC client types became any after an upgrade. Find out why
  • Migrate our tRPC v10 useQuery hooks to the v11 queryOptions integration

Use it when

  • Adding tRPC to a TypeScript monorepo or full-stack framework app
  • Writing procedures, middleware or error handling in an existing tRPC router
  • Client inference breaks or resolves to any
  • Migrating from tRPC v10 to v11

Skip it when

  • The API has third-party or non-TypeScript consumers — use rest-api-design
  • You are generating clients from an OpenAPI spec — use openapi-codegen
  • Clients are typed through Hono's hc RPC client — use hono-edge

How to check the result

tsc passes in both server and client packages, createCaller tests cover UNAUTHORIZED and NOT_FOUND for other users' records, and a validation error returns 400 with zodError details.

The common mistake it prevents

Importing the router value instead of its type in client code, which ships server modules and secrets into the browser bundle.

~/.claude/skills/trpc-apis/SKILL.md

--- name: trpc-apis description: Builds end-to-end typesafe APIs with tRPC v11 - initTRPC with typed context, routers and procedures, Zod or Standard Schema input and… license: MIT ---

Done well means: every procedure validates input and, for anything returned to browsers, output; authorization lives in reusable middleware plus per-object checks inside the procedure; the client imports only the router type; and tsc across server and client packages is the contract test.

Fit check first

SituationUse tRPC?
One TypeScript team owns client and server, same repo or monorepoYes
Next.js / Remix / TanStack Start app with its own APIYes
Public API, third-party consumers, or non-TS clients (Swift, Kotlin, Python)No; design REST with OpenAPI
Mobile app where old versions stay installed for monthsOnly with strict additive-change discipline
Service-to-service across teams and languagesNo; use gRPC or REST

Workflow

- [ ] 1. Inventory: tRPC major, adapter, client integration, schema library
- [ ] 2. Define context and the base procedures (public, protected, role-based)
- [ ] 3. Write routers per domain; validate input and output
- [ ] 4. Mount the adapter with error logging
- [ ] 5. Wire the client links and the React Query integration
- [ ] 6. Test procedures through createCaller; typecheck the client

1. Inventory

npm ls @trpc/server @trpc/client @trpc/react-query @trpc/tanstack-react-query zod superjson
grep -rn "initTRPC" --include=*.ts src | head

v10 to v11 changes that matter: transformer moves from the client constructor onto each link, the new @trpc/tanstack-react-query package replaces hook wrappers with queryOptions/mutationOptions factories (the classic @trpc/react-query still works), subscriptions over SSE via httpSubscriptionLink, and Standard Schema validators (Zod, Valibot, ArkType) are accepted directly. Stay on the integration the codebase uses unless asked to migrate.

2. Context and base procedures

// server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z, ZodError } from "zod";

export async function createContext({ req }: { req: Request }) {
  const session = await getSession(req);          // your auth lookup
  return { db, session };
}
export type Context = Awaited<ReturnType<typeof createContext>>;

const t = initTRPC.context<Context>().create({
  transformer: superjson,                         // Dates, Maps, BigInt survive the wire
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError ? z.flattenError(error.cause) : null,
      },
    };
  },
});

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

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) throw new TRPCError({ code: "UNAUTHORIZED" });
  return next({ ctx: { ...ctx, user: ctx.session.user } });   // user is non-null downstream
});

export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
  if (ctx.user.role !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
  return next();
});
  • Context creation runs per request (per batch). Keep it cheap: resolve the session, do not load the user’s whole profile.
  • Middleware that narrows ctx gives every downstream procedure a typed, non-null ctx.user; no ctx.user! assertions.

3. Routers and procedures

// server/routers/order.ts
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "../trpc";

const OrderOut = z.object({
  id: z.string(),
  status: z.enum(["pending", "paid", "cancelled"]),
  totalCents: z.number().int(),
  createdAt: z.date(),
});

export const orderRouter = router({
  byId: protectedProcedure
    .input(z.object({ id: z.uuid() }))
    .output(OrderOut)                                  // strips internal columns
    .query(async ({ ctx, input }) => {
      const order = await ctx.db.order.findFirst({
        where: { id: input.id, userId: ctx.user.id },  // per-object authorization
      });
      if (!order) throw new TRPCError({ code: "NOT_FOUND" });
      return order;
    }),

  list: protectedProcedure
    .input(z.object({ cursor: z.string().nullish(), limit: z.number().int().min(1).max(100).default(20) }))
    .query(async ({ ctx, input }) => {
      const rows = await ctx.db.order.findMany({
        where: { userId: ctx.user.id },
        take: input.limit + 1,
        ...(input.cursor && { cursor: { id: input.cursor }, skip: 1 }),
        orderBy: [{ createdAt: "desc" }, { id: "desc" }],
      });
      const nextCursor = rows.length > input.limit ? rows.pop()!.id : null;
      return { items: rows, nextCursor };
    }),

  cancel: protectedProcedure
    .input(z.object({ id: z.uuid() }))
    .mutation(async ({ ctx, input }) => {
      const res = await ctx.db.order.updateMany({
        where: { id: input.id, userId: ctx.user.id, status: "pending" },
        data: { status: "cancelled" },
      });
      if (res.count === 0) throw new TRPCError({ code: "CONFLICT", message: "Order is not cancellable" });
      return { ok: true };
    }),
});

// server/routers/_app.ts
export const appRouter = router({ order: orderRouter });
export type AppRouter = typeof appRouter;
  • query for reads (GET, cacheable), mutation for writes. Never mutate in a query.
  • Lists take a cursor and a bounded limit; useInfiniteQuery reads nextCursor.
  • .output() costs a parse per response; use it on procedures that return DB rows to browsers so a new column never leaks.

Error codes and their HTTP status:

CodeHTTPUse for
BAD_REQUEST400Input fails validation (thrown automatically)
UNAUTHORIZED401No or invalid session
FORBIDDEN403Authenticated, not allowed
NOT_FOUND404Missing, or exists but belongs to someone else
CONFLICT409Invalid state transition, duplicate
PRECONDITION_FAILED412Stale version / optimistic lock
TOO_MANY_REQUESTS429Rate limited
INTERNAL_SERVER_ERROR500Unexpected; message hidden from clients in production

4. Adapters

// app/api/trpc/[trpc]/route.ts  (Next.js App Router, fetch adapter)
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({ req }),
    onError({ path, error }) {
      if (error.code === "INTERNAL_SERVER_ERROR") console.error(`tRPC ${path}`, error);
    },
  });
export { handler as GET, handler as POST };

Express: app.use("/trpc", createExpressMiddleware({ router: appRouter, createContext })) from @trpc/server/adapters/express. Standalone: createHTTPServer from @trpc/server/adapters/standalone.

5. Client and React Query

// client/trpc.ts
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import superjson from "superjson";
import type { AppRouter } from "@/server/routers/_app";   // type-only import

export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();

export const makeTrpcClient = () =>
  createTRPCClient<AppRouter>({
    links: [
      loggerLink({ enabled: () => process.env.NODE_ENV === "development" }),
      httpBatchLink({ url: "/api/trpc", transformer: superjson, maxURLLength: 2083 }),
    ],
  });
function OrderPanel({ id }: { id: string }) {
  const trpc = useTRPC();
  const qc = useQueryClient();
  const order = useQuery(trpc.order.byId.queryOptions({ id }));
  const cancel = useMutation(
    trpc.order.cancel.mutationOptions({
      onSuccess: () => qc.invalidateQueries({ queryKey: trpc.order.pathKey() }),
    }),
  );
  // render order.data, cancel.mutate({ id })
}
  • Wrap the app in QueryClientProvider and TRPCProvider (pass trpcClient and queryClient); create both once per browser session, per request on the server.
  • pathKey() invalidates every query under order.*; queryKey(input) targets one.
  • For React Server Components, prefetch with createTRPCOptionsProxy on the server and hydrate with HydrationBoundary; check the v11 docs for the exact server setup the framework needs.
  • One slow procedure delays a whole batch on httpBatchLink. Use httpBatchStreamLink to stream results as they finish, or splitLink to send slow procedures unbatched.

6. Tests

import { createCallerFactory } from "@/server/trpc";
import { appRouter } from "@/server/routers/_app";

const createCaller = createCallerFactory(appRouter);

test("cannot read another user's order", async () => {
  const caller = createCaller({ db: testDb, session: { user: { id: "u2", role: "user" } } });
  await expect(caller.order.byId({ id: orderOfU1.id })).rejects.toMatchObject({ code: "NOT_FOUND" });
});

Callers run middleware and validation exactly as HTTP calls do, without a server.

Gotchas

  • import { appRouter } (a value) in client code bundles server code and secrets into the browser. Client files may only import type.
  • Client types collapse to any when the server package’s tsconfig is not strict, when router and client use different @trpc/* versions, or when the router type is exported through a path the client cannot resolve. Align versions and set strict: true on both.
  • Without a transformer, Date arrives as a string while the type still says Date.
  • Large inputs on queries hit URL length limits; set maxURLLength or make it a mutation.
  • Removing or renaming a procedure breaks already-deployed clients instantly. Add the new one, migrate callers, then delete.
  • publicProcedure on something that reads user data is the most common tRPC auth bug; grep for it in every review.

Verification

npx tsc --noEmit -p apps/web && npx tsc --noEmit -p packages/api
npx vitest run server/          # createCaller tests incl. UNAUTHORIZED / NOT_FOUND paths
grep -rn "publicProcedure" server/routers   # review every hit

Then load the page with the network tab open: queries batch into one request, a forced validation error returns 400 with zodError data, and a logged-out request to a protected procedure returns 401.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc