tRPC
tRPC is a remote procedure call framework built for TypeScript. It lets you define API procedures on the server and call them from the client with full end-to-end type safety. There is no code generation step, no OpenAPI document to maintain, and no runtime schema validation library required if you use Zod.
The core idea is simple: write a TypeScript function on the server, import the router type into your client, and call the function as if it were local. Input validation, serialization, error handling, and type inference are handled by the framework.
This guide covers routers, queries, mutations, context, middleware, subscriptions, and the architectural tradeoffs that help you decide whether tRPC fits your project.
Traditional API development involves writing server routes, documenting them, generating client types, and then hoping the two sides stay in sync. tRPC collapses this pipeline. The same TypeScript types drive both server implementation and client consumption.
| Concern | REST | GraphQL | tRPC |
|---|---|---|---|
| Type safety | Manual or generated | Generated from schema | Native TypeScript inference |
| Client coupling | Loose | Medium | Tight to TypeScript stack |
| Public APIs | Excellent | Excellent | Poor fit |
| Tooling | Swagger, Postman | Apollo, Relay | IDE autocomplete only |
info
A router is a collection of procedures. Procedures are the tRPC equivalent of API endpoints. There are three kinds: queries for reading data, mutations for changing data, and subscriptions for real-time updates. Procedures accept an input schema, run a resolver, and return a typed result.
| 1 | import { initTRPC } from "@trpc/server"; |
| 2 | import { z } from "zod"; |
| 3 | |
| 4 | const t = initTRPC.create(); |
| 5 | |
| 6 | const appRouter = t.router({ |
| 7 | user: t.router({ |
| 8 | byId: t.procedure |
| 9 | .input(z.object({ id: z.string().uuid() })) |
| 10 | .query(async ({ input, ctx }) => { |
| 11 | return ctx.userRepository.findById(input.id); |
| 12 | }), |
| 13 | |
| 14 | create: t.procedure |
| 15 | .input(z.object({ |
| 16 | email: z.string().email(), |
| 17 | name: z.string().min(1).optional(), |
| 18 | })) |
| 19 | .mutation(async ({ input, ctx }) => { |
| 20 | return ctx.userRepository.create(input); |
| 21 | }), |
| 22 | |
| 23 | list: t.procedure |
| 24 | .input(z.object({ |
| 25 | cursor: z.string().optional(), |
| 26 | limit: z.number().min(1).max(100).default(20), |
| 27 | })) |
| 28 | .query(async ({ input, ctx }) => { |
| 29 | return ctx.userRepository.list({ |
| 30 | cursor: input.cursor, |
| 31 | limit: input.limit, |
| 32 | }); |
| 33 | }), |
| 34 | }), |
| 35 | }); |
| 36 | |
| 37 | export type AppRouter = typeof appRouter; |
best practice
Context is the dependency injection layer of tRPC. It is created once per request and passed to every procedure. Typical context values include the authenticated user, database connections, request IDs, and feature flags. Keep context serializable and avoid putting heavy objects into it unless necessary.
| 1 | import { initTRPC } from "@trpc/server"; |
| 2 | import { createHTTPHandler } from "@trpc/server/adapters/standalone"; |
| 3 | |
| 4 | interface User { |
| 5 | id: string; |
| 6 | email: string; |
| 7 | role: "admin" | "member"; |
| 8 | } |
| 9 | |
| 10 | interface Context { |
| 11 | user: User | null; |
| 12 | db: Database; |
| 13 | requestId: string; |
| 14 | } |
| 15 | |
| 16 | const createContext = async ({ req }): Promise<Context> => { |
| 17 | const token = req.headers.authorization?.replace("Bearer ", ""); |
| 18 | const user = token ? await verifyToken(token) : null; |
| 19 | return { |
| 20 | user, |
| 21 | db: getDatabase(), |
| 22 | requestId: req.headers["x-request-id"] || crypto.randomUUID(), |
| 23 | }; |
| 24 | }; |
| 25 | |
| 26 | const t = initTRPC.context<Context>().create(); |
| 27 | |
| 28 | // Use context in procedures |
| 29 | const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { |
| 30 | if (!ctx.user) { |
| 31 | throw new TRPCError({ code: "UNAUTHORIZED" }); |
| 32 | } |
| 33 | return next({ ctx: { user: ctx.user } }); |
| 34 | }); |
warning
Middleware in tRPC wraps procedures and can modify context, enforce authorization, log requests, or rate limit calls. Middleware runs before the resolver and can short-circuit by throwing errors. Compose middleware with the .use chain.
| 1 | import { TRPCError } from "@trpc/server"; |
| 2 | import { z } from "zod"; |
| 3 | |
| 4 | // Enforce authentication |
| 5 | const authed = t.middleware(async ({ ctx, next }) => { |
| 6 | if (!ctx.user) { |
| 7 | throw new TRPCError({ code: "UNAUTHORIZED" }); |
| 8 | } |
| 9 | return next({ ctx: { user: ctx.user } }); |
| 10 | }); |
| 11 | |
| 12 | // Enforce a specific role |
| 13 | const requireRole = (role: string) => |
| 14 | t.middleware(async ({ ctx, next }) => { |
| 15 | if (ctx.user?.role !== role) { |
| 16 | throw new TRPCError({ code: "FORBIDDEN" }); |
| 17 | } |
| 18 | return next(); |
| 19 | }); |
| 20 | |
| 21 | // Rate limit by user ID |
| 22 | const rateLimited = t.middleware(async ({ ctx, next }) => { |
| 23 | const key = `rate:${ctx.user?.id || ctx.requestId}`; |
| 24 | const remaining = await ctx.rateLimiter.consume(key); |
| 25 | if (remaining < 0) { |
| 26 | throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); |
| 27 | } |
| 28 | return next(); |
| 29 | }); |
| 30 | |
| 31 | // Compose middleware into reusable procedures |
| 32 | const protectedProcedure = t.procedure.use(authed); |
| 33 | const adminProcedure = protectedProcedure.use(requireRole("admin")); |
| 34 | const adminRateLimited = adminProcedure.use(rateLimited); |
| 35 | |
| 36 | adminRateLimited |
| 37 | .input(z.object({ reason: z.string() })) |
| 38 | .mutation(async ({ input, ctx }) => { |
| 39 | // admin-only, rate-limited operation |
| 40 | }); |
best practice
The client side of tRPC imports the router type from the server and uses it to create a fully typed client. In React, tRPC integrates with TanStack Query through @trpc/react-query. You get typed hooks for queries and mutations, caching, and optimistic updates without writing fetch calls.
| 1 | // client/trpc.ts |
| 2 | import { createTRPCReact } from "@trpc/react-query"; |
| 3 | import type { AppRouter } from "../server/router"; |
| 4 | |
| 5 | export const trpc = createTRPCReact<AppRouter>(); |
| 6 | |
| 7 | // React component |
| 8 | function UserProfile({ userId }: { userId: string }) { |
| 9 | const { data, isLoading, error } = trpc.user.byId.useQuery({ id: userId }); |
| 10 | const createUser = trpc.user.create.useMutation({ |
| 11 | onSuccess: () => { |
| 12 | utils.user.list.invalidate(); |
| 13 | }, |
| 14 | }); |
| 15 | |
| 16 | if (isLoading) return <p>Loading...</p>; |
| 17 | if (error) return <p>Error: {error.message}</p>; |
| 18 | |
| 19 | return ( |
| 20 | <div> |
| 21 | <h1>{data?.name || data?.email}</h1> |
| 22 | <button |
| 23 | onClick={() => |
| 24 | createUser.mutate({ email: "new@example.com", name: "New User" }) |
| 25 | } |
| 26 | > |
| 27 | Create user |
| 28 | </button> |
| 29 | </div> |
| 30 | ); |
| 31 | } |
note
tRPC procedures throw TRPCError objects with standardized codes. The client receives shaped errors that include a message and optional data. Map domain exceptions to TRPCError codes at the boundary of your procedure.
| 1 | import { TRPCError } from "@trpc/server"; |
| 2 | |
| 3 | const userRouter = t.router({ |
| 4 | update: protectedProcedure |
| 5 | .input(z.object({ id: z.string(), name: z.string() })) |
| 6 | .mutation(async ({ input, ctx }) => { |
| 7 | const user = await ctx.db.user.findUnique({ where: { id: input.id } }); |
| 8 | if (!user) { |
| 9 | throw new TRPCError({ |
| 10 | code: "NOT_FOUND", |
| 11 | message: "User not found", |
| 12 | }); |
| 13 | } |
| 14 | if (user.id !== ctx.user.id && ctx.user.role !== "admin") { |
| 15 | throw new TRPCError({ |
| 16 | code: "FORBIDDEN", |
| 17 | message: "You can only update your own profile", |
| 18 | }); |
| 19 | } |
| 20 | return ctx.db.user.update({ |
| 21 | where: { id: input.id }, |
| 22 | data: { name: input.name }, |
| 23 | }); |
| 24 | }), |
| 25 | }); |
| 26 | |
| 27 | // Client receives |
| 28 | // { |
| 29 | // "error": { |
| 30 | // "json": { |
| 31 | // "message": "User not found", |
| 32 | // "code": -32004, |
| 33 | // "data": { "code": "NOT_FOUND", "httpStatus": 404 } |
| 34 | // } |
| 35 | // } |
| 36 | // } |
best practice
Choose tRPC when your team values type safety and owns both the frontend and backend in TypeScript. It removes the friction of API contracts and makes refactors safer. It is particularly effective for Next.js applications using the app router or pages router, dashboards, internal tools, and SaaS products.
Avoid tRPC when you need to expose APIs to external developers, mobile SDKs, or services written in other languages. In those cases, the tight coupling to TypeScript becomes a liability. REST with OpenAPI or GraphQL are better public-facing choices.
| Use tRPC | Do Not Use tRPC |
|---|---|
| Full-stack TypeScript monorepo | Public API platform |
| Single team owns client and server | Polyglot microservices |
| Rapid iteration and frequent refactors | Long-term stable contract required |
| Next.js, Nuxt, or similar frameworks | Third-party integrations |
pro tip