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.
- name
trpc-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.
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
| Situation | Use tRPC? |
|---|---|
| One TypeScript team owns client and server, same repo or monorepo | Yes |
| Next.js / Remix / TanStack Start app with its own API | Yes |
| 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 months | Only with strict additive-change discipline |
| Service-to-service across teams and languages | No; 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
ctxgives every downstream procedure a typed, non-nullctx.user; noctx.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;
queryfor reads (GET, cacheable),mutationfor writes. Never mutate in a query.- Lists take a cursor and a bounded
limit;useInfiniteQueryreadsnextCursor. .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:
| Code | HTTP | Use for |
|---|---|---|
BAD_REQUEST | 400 | Input fails validation (thrown automatically) |
UNAUTHORIZED | 401 | No or invalid session |
FORBIDDEN | 403 | Authenticated, not allowed |
NOT_FOUND | 404 | Missing, or exists but belongs to someone else |
CONFLICT | 409 | Invalid state transition, duplicate |
PRECONDITION_FAILED | 412 | Stale version / optimistic lock |
TOO_MANY_REQUESTS | 429 | Rate limited |
INTERNAL_SERVER_ERROR | 500 | Unexpected; 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
QueryClientProviderandTRPCProvider(passtrpcClientandqueryClient); create both once per browser session, per request on the server. pathKey()invalidates every query underorder.*;queryKey(input)targets one.- For React Server Components, prefetch with
createTRPCOptionsProxyon the server and hydrate withHydrationBoundary; check the v11 docs for the exact server setup the framework needs. - One slow procedure delays a whole batch on
httpBatchLink. UsehttpBatchStreamLinkto stream results as they finish, orsplitLinkto 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 onlyimport type.- Client types collapse to
anywhen the server package’stsconfigis notstrict, 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 setstrict: trueon both. - Without a transformer,
Datearrives as a string while the type still saysDate. - Large inputs on queries hit URL length limits; set
maxURLLengthor make it a mutation. - Removing or renaming a procedure breaks already-deployed clients instantly. Add the new one, migrate callers, then delete.
publicProcedureon 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.