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/web and packages/api without 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:

  1. product.list — cursor-paginated query with Zod input { query?, category?, minPrice?, cursor? }. Returns { items, nextCursor }. Backed by PostgreSQL full-text search via Prisma.
  2. product.byId — single product with variants. Output-validated against a Zod schema so missing SKU fields fail loudly in staging.
  3. product.categories — static-ish facet counts; client sets staleTime: 60_000 via TanStack Query meta override on the hook options.
  4. cart.reserve — protected mutation decrementing stock with optimistic UI. Middleware checks ctx.user; mutation throws CONFLICT when inventory insufficient.
  5. admin.product.createprotectedProcedure plus isAdmin middleware. Audit log written in middleware onSuccess hook 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 repoPublic API serves many languages and external partnersClients need flexible field selection across deep object graphs
Rapid iteration without codegen pipelinesOrg mandates OpenAPI-first governance and contract testsMobile apps with bandwidth constraints query only needed fields
Team already uses TanStack Query + ZodHTTP caching at CDN edge on GET resources is criticalMultiple teams contribute to one graph with federation
Internal tools and B2B SaaS dashboardsSimple CRUD with mature API gateway toolingReal-time subscriptions via GraphQL live queries are core
Procedure-level auth middleware is sufficientWebhook receivers need stable URL paths documented externallyApollo 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 TRPCError with safe messages; log internals server-side only.
  • Missing request context in serverless — re-create db per 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 protectedProcedure and role middleware; never check auth only in the client.
  • Export type AppRouter from API package; client imports type-only.
  • Wire TRPCProvider with TanStack Query at app root; configure default staleTime per 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 TRPCError codes consistently.
  • Unit-test procedures with createCaller and mocked context; avoid HTTP in unit tests.
  • Document which procedures are public vs protected; audit publicProcedure usage 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