Guide
tRPC fundamentals explained
A frontend engineer renames a field from productName to
title on the server. The React client still compiles — until
production users see blank cards because nobody updated the fetch layer. REST teams
solve this with OpenAPI specs and codegen pipelines that drift anyway. GraphQL adds
schema stitching overhead. tRPC takes a different path: define API
procedures once in TypeScript on the server, and the client infers input and output
types automatically through a shared router type — no code generation step,
no duplicate DTO interfaces. Combined with
Zod for runtime
validation and
TanStack Query
for caching, tRPC has become the default stack for full-stack TypeScript teams
shipping
Next.js or
Vite monorepos. This guide covers routers and procedures, input validation,
context and middleware, SuperJSON serialization, client hooks, a Harbor Commerce
catalog API worked example, a framework decision table, common pitfalls, and a
production checklist.
What tRPC is and why TypeScript teams adopt it
tRPC (TypeScript Remote Procedure Call) is a library for building type-safe APIs where the server exports a router — a tree of named procedures — and the client imports that router’s type to get fully typed hooks and callers. The wire format is JSON over HTTP (or other adapters); what changes is that types flow through compile-time inference instead of a separate schema file maintained by hand.
A procedure is either a query (read, idempotent, cacheable) or a
mutation (write, side effects). This mirrors TanStack Query’s
mental model, which is why the official @trpc/react-query integration
feels natural. Queries map to useQuery; mutations map to
useMutation with automatic invalidation helpers.
When tRPC is the right default
- Full-stack TypeScript monorepos — shared types between
apps/webandpackages/apiwithout OpenAPI codegen. - Small to medium product teams — internal dashboards, SaaS CRUD, e-commerce catalogs where API surface changes weekly.
- Next.js App Router or t3 stack projects — server components can call procedures directly; client components use hooks.
- Teams already on Zod — input schemas double as runtime validators and TypeScript type sources.
Skip tRPC when your API must serve non-TypeScript clients (mobile Swift/Kotlin, third-party public webhooks) as the primary consumers, when you need a mature public API marketplace with versioned contracts, or when organizational policy mandates OpenAPI-first governance regardless of client language.
Routers, procedures, and the type inference chain
The server entry point initializes tRPC with context typing and exports
router, publicProcedure, and optionally
middleware builders. Each feature area gets a sub-router merged
into appRouter:
// packages/api/src/root.ts
export const appRouter = router({
product: productRouter,
order: orderRouter,
user: userRouter,
});
export type AppRouter = typeof appRouter;
The critical export is type AppRouter = typeof appRouter. The
client imports only this type — erased at runtime — and
passes it to createTRPCReact<AppRouter>(). Every
trpc.product.list.useQuery() call knows its input shape and return
type because TypeScript resolves the router tree statically.
Queries vs mutations
- Queries —
.query(async ({ input, ctx }) => ...). Safe to retry; should not mutate database state. HTTP mapping defaults to GET for batched requests or POST for single calls depending on adapter. - Mutations —
.mutation(async ({ input, ctx }) => ...). Create, update, delete operations. Never cached as reads; pair with query invalidation on success.
Nested routers keep large APIs navigable. A productRouter might
expose list, byId, create, and
updateStock procedures. Naming follows REST-like semantics but
without URL path design debates — the procedure path is
product.list, product.byId, etc.
Input validation with Zod
Runtime validation is not optional in production APIs. tRPC integrates Zod
(or other validators via @trpc/server adapters) through
.input(schema) chained before .query or
.mutation:
const listProductsInput = z.object({
category: z.string().optional(),
cursor: z.string().uuid().optional(),
limit: z.number().int().min(1).max(50).default(20),
});
export const productRouter = router({
list: publicProcedure
.input(listProductsInput)
.query(async ({ input, ctx }) => {
return ctx.db.product.findMany({
take: input.limit,
cursor: input.cursor ? { id: input.cursor } : undefined,
where: input.category ? { category: input.category } : undefined,
});
}),
});
Invalid input returns a structured TRPCError with code
BAD_REQUEST before your handler runs — no manual
if (!input.limit) guards scattered through business logic. The
inferred TypeScript type of input inside the handler matches the
Zod schema exactly, so refactoring the schema updates both validation and types
in one edit.
Output validation (optional but valuable)
.output(schema) validates responses before serialization. Use it
at API boundaries when handlers call external services or raw SQL that might
return unexpected nulls. The performance cost is negligible compared to database
round-trips; the benefit is catching shape regressions in CI when procedures are
unit-tested.
Context, middleware, and authorization
Context (ctx) is created per request in the
adapter’s createContext function. Typical fields: authenticated
session, db Prisma client, req headers,
and request-scoped loggers. Context types flow into every procedure.
Middleware wraps procedures to enforce cross-cutting rules before handlers execute. The idiomatic pattern builds derived procedures:
const isAuthed = middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.session.user } });
});
export const protectedProcedure = publicProcedure.use(isAuthed);
protectedProcedure narrows ctx.user to a non-null type
in downstream handlers — TypeScript enforces that order.create
cannot be called without authentication at compile time on the server, and
unauthorized calls fail with consistent error codes on the wire.
Role-based access
Stack middleware: isAuthed then isAdmin. Keep
authorization logic in middleware or small policy functions, not duplicated
inside every mutation. Log UNAUTHORIZED and FORBIDDEN
separately for security auditing.
SuperJSON and custom transformers
JSON.stringify cannot represent Date, Map,
Set, BigInt, or undefined. tRPC defaults
to plain JSON, which silently coerces dates to strings on the client without
type narrowing. SuperJSON is the standard transformer: register
it on both server and client via initTRPC.create({ transformer: superjson }).
With SuperJSON, a procedure returning { createdAt: new Date() }
deserializes back to a real Date on the client — TanStack
Query cache entries preserve types correctly. For APIs returning decimal money
values, some teams wrap Decimal types from Prisma or
decimal.js with custom transformers instead of floating-point JSON
numbers.
Transformer choice must match on server and client. Mismatched transformers
produce cryptic parse errors; enforce a shared packages/api/trpc.ts
module imported by both sides in a monorepo.
Client setup with TanStack Query
The React client wires three pieces: createTRPCReact<AppRouter>(),
a QueryClient, and trpc.createClient({ links: [...] })
inside TRPCProvider. The HTTP link points at your API route
(e.g. /api/trpc in Next.js).
// Client usage — fully typed, no manual fetch
const { data, isLoading } = trpc.product.list.useQuery({ category: 'tools' });
const createProduct = trpc.product.create.useMutation({
onSuccess: () => utils.product.list.invalidate(),
});
trpc.useUtils() exposes invalidation, prefetch, and
setData helpers scoped to procedure paths. This replaces hand-written
cache key strings and keeps invalidation aligned with server router structure.
Server-side calls
In Next.js server components or route handlers, use
createCallerFactory(appRouter) to invoke procedures directly without
HTTP overhead. The same types apply; SSR pages can prefetch with
await trpc.product.list.fetch() and dehydrate into the client
QueryClient for zero loading flash.
Worked example: Harbor Commerce catalog API
Harbor Commerce is a fictional B2B marketplace used across our backend guides. Their catalog API exposes product search, category facets, and inventory reservations at checkout. A tRPC layout:
product.list— cursor-paginated query with Zod input{ query?, category?, minPrice?, cursor? }. Returns{ items, nextCursor }. Backed by PostgreSQL full-text search via Prisma.product.byId— single product with variants. Output-validated against a Zod schema so missing SKU fields fail loudly in staging.product.categories— static-ish facet counts; client setsstaleTime: 60_000via TanStack Query meta override on the hook options.cart.reserve— protected mutation decrementing stock with optimistic UI. Middleware checksctx.user; mutation throwsCONFLICTwhen inventory insufficient.admin.product.create—protectedProcedureplusisAdminmiddleware. Audit log written in middlewareonSuccesshook pattern.
The Vite storefront imports AppRouter type only. Adding
product.list filter inStock: boolean updates the React
filter component’s props in the same PR — TypeScript errors if any
caller omits the new optional field incorrectly. E2E tests use a test router
with mocked ctx.db rather than HTTP stubs, keeping procedure
contracts verified.
Framework decision table
| Choose tRPC when… | Choose REST + OpenAPI when… | Choose GraphQL when… |
|---|---|---|
| Both client and server are TypeScript in one repo | Public API serves many languages and external partners | Clients need flexible field selection across deep object graphs |
| Rapid iteration without codegen pipelines | Org mandates OpenAPI-first governance and contract tests | Mobile apps with bandwidth constraints query only needed fields |
| Team already uses TanStack Query + Zod | HTTP caching at CDN edge on GET resources is critical | Multiple teams contribute to one graph with federation |
| Internal tools and B2B SaaS dashboards | Simple CRUD with mature API gateway tooling | Real-time subscriptions via GraphQL live queries are core |
| Procedure-level auth middleware is sufficient | Webhook receivers need stable URL paths documented externally | Apollo normalized cache replaces TanStack Query patterns |
For gRPC and protobuf-first microservices, see our gRPC guide. tRPC is not a replacement for service-to-service RPC at scale; it excels at the browser-to-API boundary in TypeScript ecosystems.
Common pitfalls
- Exporting runtime router to client bundles — import only
type AppRouter; server code must not leak into client chunks. - Mismatched SuperJSON config — dates arrive as strings; comparisons silently fail. Share one transformer module.
- God routers — one 2,000-line router file becomes unmergeable; split by domain early.
- Skipping output validation on external data — Prisma schema changes can return null relation fields that UI does not handle.
- Mutations that should be queries — marking read endpoints as mutations disables GET caching and confuses TanStack Query semantics.
- Leaking raw database errors — catch Prisma exceptions and map to
TRPCErrorwith safe messages; log internals server-side only. - Missing request context in serverless — re-create
dbper invocation; global singleton Prisma clients exhaust connections under load. - Version skew in monorepos — client deployed before server when input schemas change; use coordinated deploys or backward-compatible optional fields.
Practitioner checklist
- Initialize tRPC with SuperJSON (or chosen transformer) in a shared package imported by server and client.
- Define Zod input schemas for every procedure; add output schemas on external-facing boundaries.
- Build
protectedProcedureand role middleware; never check auth only in the client. - Export
type AppRouterfrom API package; client imports type-only. - Wire
TRPCProviderwith TanStack Query at app root; configure defaultstaleTimeper resource class. - Use
trpc.useUtils()for invalidation after mutations; prefer narrow procedure-path invalidation. - Prefetch on server (Next.js) for critical queries; dehydrate into client QueryClient.
- Map database and validation errors to
TRPCErrorcodes consistently. - Unit-test procedures with
createCallerand mocked context; avoid HTTP in unit tests. - Document which procedures are public vs protected; audit
publicProcedureusage quarterly.
Key takeaways
- tRPC infers API types from server routers to TypeScript clients without OpenAPI codegen.
- Procedures are queries (reads) or mutations (writes), aligned with TanStack Query patterns.
- Zod provides runtime input validation and static types in one schema definition.
- Middleware centralizes authentication and authorization with narrowed context types.
- SuperJSON preserves Dates and other non-JSON types across the wire when configured on both ends.
Related reading
- TypeScript fundamentals explained — types, generics, and inference tRPC relies on
- TanStack Query fundamentals explained — server state caching paired with tRPC hooks
- Zod fundamentals explained — runtime schemas for procedure inputs
- Next.js fundamentals explained — App Router API routes and server components with tRPC