|$ curl https://forge-ai.dev/api/markdown?path=docs/backend/trpc
$cat docs/trpc.md
updated Recently·24 min read·published

tRPC

BackendTypeScriptIntermediate🎯Free Tools
Introduction

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.

Why tRPC?

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.

ConcernRESTGraphQLtRPC
Type safetyManual or generatedGenerated from schemaNative TypeScript inference
Client couplingLooseMediumTight to TypeScript stack
Public APIsExcellentExcellentPoor fit
ToolingSwagger, PostmanApollo, RelayIDE autocomplete only

info

tRPC shines in full-stack TypeScript monorepos where the same team owns client and server. If you need a public API or polyglot clients, REST or GraphQL remain better choices.
Routers and Procedures

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.

trpc-router.ts
TypeScript
1import { initTRPC } from "@trpc/server";
2import { z } from "zod";
3
4const t = initTRPC.create();
5
6const 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
37export type AppRouter = typeof appRouter;

best practice

Compose routers from smaller domain routers. A single giant router becomes hard to navigate. Use t.mergeRouters or nested routers to organize by feature.
Context

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.

trpc-context.ts
TypeScript
1import { initTRPC } from "@trpc/server";
2import { createHTTPHandler } from "@trpc/server/adapters/standalone";
3
4interface User {
5 id: string;
6 email: string;
7 role: "admin" | "member";
8}
9
10interface Context {
11 user: User | null;
12 db: Database;
13 requestId: string;
14}
15
16const 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
26const t = initTRPC.context<Context>().create();
27
28// Use context in procedures
29const 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

Do not store request-scoped mutable state in context. Context should be read-only for the duration of the request. Use it to pass dependencies and identity, not as a cache for procedure results.
Middleware

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.

trpc-middleware.ts
TypeScript
1import { TRPCError } from "@trpc/server";
2import { z } from "zod";
3
4// Enforce authentication
5const 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
13const 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
22const 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
32const protectedProcedure = t.procedure.use(authed);
33const adminProcedure = protectedProcedure.use(requireRole("admin"));
34const adminRateLimited = adminProcedure.use(rateLimited);
35
36adminRateLimited
37 .input(z.object({ reason: z.string() }))
38 .mutation(async ({ input, ctx }) => {
39 // admin-only, rate-limited operation
40 });

best practice

Name middleware after the cross-cutting concern it implements. Reusable procedure builders such as protectedProcedure and adminProcedure make authorization explicit and reduce repetition.
Client Usage

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.

trpc-client.tsx
TypeScript
1// client/trpc.ts
2import { createTRPCReact } from "@trpc/react-query";
3import type { AppRouter } from "../server/router";
4
5export const trpc = createTRPCReact<AppRouter>();
6
7// React component
8function 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

The AppRouter type is the only thing shared between server and client. It carries full input and output types, so renaming a procedure input property becomes a compile error on both sides.
Error Handling

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.

trpc-errors.ts
TypeScript
1import { TRPCError } from "@trpc/server";
2
3const 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

Use specific TRPCError codes. The framework maps them to HTTP status codes automatically. Avoid generic INTERNAL_SERVER_ERROR for conditions the client can handle.
When to Use tRPC

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 tRPCDo Not Use tRPC
Full-stack TypeScript monorepoPublic API platform
Single team owns client and serverPolyglot microservices
Rapid iteration and frequent refactorsLong-term stable contract required
Next.js, Nuxt, or similar frameworksThird-party integrations
🔥

pro tip

You can combine tRPC with REST in the same application. Use tRPC for internal client-server communication and expose a small REST surface for external integrations.
$Blueprint — Engineering Documentation·Section ID: BE-03·Revision: 1.0