Guide
tRPC fundamentals explained
REST APIs ship OpenAPI specs; GraphQL ships schema files; both require codegen or hand-maintained client types that drift from the server. tRPC takes a different path: you write your API as plain TypeScript functions on the server, and the client imports the inferred types directly — no code generation step, no duplicate DTO definitions. A procedure is a typed remote function; a router is a namespace of procedures; middleware runs auth and logging before handlers execute. Teams on full-stack TypeScript (Next.js, Vite + React, T3 stack) adopt tRPC when they want compile-time guarantees that a renamed field or stricter validator breaks the build instead of production. The tradeoffs matter: tRPC is TypeScript-only on both ends, not ideal for public third-party APIs, and HTTP caching semantics differ from resource-oriented REST. This guide covers initialization, routers and procedures, input validation with Zod, context and middleware, error handling, SuperJSON serialization, TanStack Query client integration, a Harbor Commerce catalog API worked example, a framework decision table, common pitfalls, and a practitioner checklist.
What tRPC is
tRPC is a library, not a transport protocol. Under the hood it serializes procedure
calls over HTTP (or serverless function invocations) as batched JSON POST requests.
The magic is entirely in TypeScript’s type inference: AppRouter is
exported from the server and imported as a type on the client, so
trpc.product.list.useQuery() knows exact input shapes and return types.
Core building blocks:
- initTRPC — factory that creates procedure builders bound to your context type and metadata.
- Router — tree of procedures and nested routers (e.g.
product.list,product.create). - Procedure —
query(read, cacheable),mutation(write), orsubscription(streaming). - Context — per-request object (session, database pool, trace id) created once and passed to every procedure.
- Middleware — composable wrappers that can enrich context, enforce auth, or short-circuit with errors.
tRPC does not replace your HTTP framework. You mount the tRPC handler on Fastify, Express, Next.js API routes, or the Fetch adapter for edge runtimes. It replaces the hand-written REST route layer and the client SDK you would otherwise maintain.
Initializing and defining procedures
A minimal server setup:
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
type Context = { userId: string | null; db: Database }
const t = initTRPC.context<Context>().create()
export const router = t.router
export const publicProcedure = t.procedure
const productRouter = router({
list: publicProcedure
.input(z.object({ category: z.string().optional(), limit: z.number().max(50).default(20) }))
.query(async ({ input, ctx }) => {
return ctx.db.products.findMany({ where: { category: input.category }, take: input.limit })
}),
create: publicProcedure
.input(z.object({ sku: z.string(), name: z.string(), priceCents: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
return ctx.db.products.create({ data: input })
}),
})
export const appRouter = router({ product: productRouter })
export type AppRouter = typeof appRouter
Input validation
The .input() chain accepts Zod schemas (recommended), Valibot, or other
validators tRPC supports. Invalid input returns a BAD_REQUEST error with
structured zodError metadata before your handler runs — same
guarantee as schema-first HTTP frameworks, but types flow to the client automatically.
Queries vs mutations
Semantically, query procedures should be idempotent reads;
mutation procedures perform writes. TanStack Query maps queries to
useQuery and mutations to useMutation with correct cache
invalidation hooks. Subscriptions use WebSockets or SSE adapters for live feeds
(inventory deltas, order status) but add operational complexity most catalog APIs
skip until needed.
Context, middleware, and authorization
Context is created per request in your adapter’s createContext
function:
export async function createContext({ req }: CreateContextOptions): Promise<Context> {
const session = await verifySession(req.headers.get('authorization'))
return { userId: session?.userId ?? null, db: getDb() }
}
Middleware chains behavior before procedures execute:
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, userId: ctx.userId } })
})
const protectedProcedure = publicProcedure.use(isAuthed)
Use middleware for authentication, role checks, rate limiting, and audit logging.
Keep context slim — attach service facades or repositories, not raw ORM
instances with implicit global state. Scope admin-only routers behind
protectedProcedure and separate adminRouter namespaces so
public catalog queries never accidentally import privileged mutations.
Errors, serialization, and the HTTP adapter
Throw TRPCError with standard codes (NOT_FOUND,
FORBIDDEN, CONFLICT, INTERNAL_SERVER_ERROR).
The client receives typed error shapes; TanStack Query surfaces them in
error.data for toast messages or form field mapping.
JSON cannot natively serialize Date, Map,
bigint, or undefined. Enable
SuperJSON as the transformer on both server and client:
import superjson from 'superjson'
const t = initTRPC.context<Context>().create({ transformer: superjson })
Without a transformer, you will silently coerce Dates to strings and lose type
fidelity. Mount the router via the Fetch adapter (edge-friendly), Node HTTP, or
Next.js app/api/trpc/[trpc]/route.ts. Enable
request batching so multiple useQuery calls on one
page collapse into a single HTTP round-trip.
Client integration with TanStack Query
On the client, create a typed proxy:
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../server/router'
export const trpc = createTRPCReact<AppRouter>()
Wrap your app with trpc.Provider and QueryClientProvider.
Components call hooks directly:
function ProductGrid() {
const { data, isLoading, error } = trpc.product.list.useQuery({ category: 'apparel', limit: 12 })
const create = trpc.product.create.useMutation({
onSuccess: () => utils.product.list.invalidate(),
})
// ...
}
Invalidation is hierarchical: utils.product.invalidate() refreshes all
product procedures. For optimistic UI on cart updates, use
onMutate / onError rollback patterns documented in
TanStack Query. Server Components in Next.js App Router can call
createCallerFactory for direct server-side procedure invocation without
HTTP — useful for initial page data that must not flash loading states.
Worked example: Harbor Commerce catalog API
Harbor Commerce, a fictional B2B wholesale platform, replaced a 40-route Express REST surface with tRPC to stop frontend/backend type drift during rapid catalog changes.
Architecture
- productRouter —
list,bySku,search(public queries);create,updateStock(protected mutations). - orderRouter —
cart.addLine,cart.checkout,order.statusnested underorder. - Context — JWT session, Prisma client, Redis cache for
product.bySku. - Middleware —
isAuthedfor writes;isBuyerOrgchecks org tier before bulk pricing queries.
Key decisions
Search used .input(z.object({ q: z.string().min(2), cursor: z.string().optional() }))
with cursor-based pagination returned as { items, nextCursor } —
TanStack Query’s useInfiniteQuery mapped cleanly. Stock mutations
invalidated both product.bySku and product.list cache keys.
SuperJSON preserved lastRestockedAt: Date on product records.
Results
Type mismatch production bugs dropped to zero over six months (previously 2–3 per sprint from manual OpenAPI client updates). Time-to-ship new catalog fields fell from three PRs (schema, route, client) to one. Tradeoff: mobile clients in Kotlin could not consume tRPC natively — Harbor exposed a thin REST read-only facade for the warehouse scanner app while the web dashboard stayed fully typed.
Framework decision table
| Choose tRPC when… | Prefer REST + OpenAPI when… | Consider GraphQL when… |
|---|---|---|
| Both ends are TypeScript in one monorepo | Public API with polyglot clients (Swift, Kotlin, Python) | Clients need flexible field selection across deep graphs |
| Type safety without codegen is the goal | HTTP caching at CDN edge on GET resources matters | Many teams already standardized on Apollo/Relay |
| TanStack Query powers the frontend | Regulatory audit requires stable URL-per-resource semantics | Mobile apps share one schema with web and admin tools |
| Small team, fast iteration on shared types | Third-party integrators expect conventional REST | Nested data fetching would cause N+1 REST round-trips |
| Next.js / T3-style full-stack apps | API gateway translates to gRPC or SOAP upstream | See GraphQL API design for schema-first tradeoffs |
Common pitfalls
- God routers — one flat router with 80 procedures; split by domain and merge with
router({ product, order, user }). - Skipping input validators —
.input()omitted “because TypeScript is enough”; runtime still receives malformed JSON from browsers. - Mutations that read — side-effect-free lookups marked as mutations lose TanStack Query deduplication and refetch-on-focus behavior.
- Leaking internal errors — forwarding raw database exceptions to clients; map to
TRPCErrorwith safe messages. - No transformer parity — SuperJSON on server but default JSON on client breaks Date types silently.
- Context bloat — loading entire user profiles and permissions for every public catalog query.
- Assuming tRPC replaces REST for partners — external integrators need OpenAPI or GraphQL, not your internal AppRouter import.
- Unbounded list procedures — missing
limitcaps onlistqueries; always paginate or cursor.
Practitioner checklist
- Export
AppRoutertype from server; never duplicate procedure signatures on the client. - Validate every procedure input with Zod; use
.output()sparingly for critical contracts. - Configure SuperJSON (or agreed transformer) on both server init and client.
- Split routers by domain; use
protectedProcedurefor any write or PII read. - Implement
createContextwith request-scoped db connections, not globals. - Enable HTTP batching for pages with multiple concurrent queries.
- Map
TRPCErrorcodes to user-visible messages; log internals server-side only. - Document cache invalidation rules per mutation in code comments or team runbook.
- Add integration tests via
createCallerFactorycalling procedures directly. - Plan a REST or GraphQL escape hatch if non-TypeScript clients are on the roadmap.
Key takeaways
- tRPC shares TypeScript types from server to client without OpenAPI codegen.
- Procedures are typed remote functions; routers namespace them for scalable APIs.
- Zod input validates at runtime while inferring client types at compile time.
- Middleware and context centralize auth, logging, and per-request dependencies.
- TanStack Query is the natural client layer for caching, mutations, and invalidation.
Related reading
- TypeScript fundamentals explained — types, generics, and inference tRPC depends on
- Zod fundamentals explained — runtime validation and
z.inferfor procedure inputs - TanStack Query fundamentals explained — caching, mutations, and invalidation with tRPC hooks
- GraphQL API design explained — when schema-first graphs beat RPC for your clients