tRPC in Production: End-to-End Type Safety Without Code Generation
A production-focused guide to tRPC covering router design, middleware, error handling, batching, streaming, and honest comparisons to REST+OpenAPI and GraphQL+codegen. Includes when tRPC is the wrong choice.
Most type-safety problems at API boundaries are not solved by TypeScript alone. TypeScript stops at the module boundary. The moment a response travels over HTTP, you are back to unknown on the other side, and you need a codegen pipeline, a manual cast, or enough discipline to never get it wrong. Teams pick one and almost always regret it.
tRPC takes a different approach. Instead of generating client code from a schema, it exports the router’s TypeScript type directly. The client imports that type (never the implementation) and gets full autocompletion and type checking with zero code generation. No OpenAPI spec, no GraphQL schema, no npm run generate step in your CI pipeline.
This works well in monorepos and full-stack TypeScript applications where both the client and server live in the same repository. It starts to fall apart in other contexts. The goal here is to show you exactly where tRPC shines, where it does not, and how to run it in production without surprises.
How the Type Sharing Actually Works
The core mechanic is deceptively simple. You define a router on the server with typed procedures. The router type gets exported. The client imports only that type, never the runtime code. TypeScript’s type-level imports (import type) make this safe: the bundle never includes server-side logic.
// server/router.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.context<{ userId: string }>().create();
export const appRouter = t.router({
user: t.router({
byId: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
update: t.procedure
.input(z.object({ id: z.string(), name: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
return db.user.update({ where: { id: input.id }, data: { name: input.name } });
}),
}),
});
export type AppRouter = typeof appRouter;
// client/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/router";
export const trpc = createTRPCReact<AppRouter>();
That import type is the load-bearing detail. The server router type travels across the module boundary at compile time only. At runtime, HTTP requests go to the tRPC handler. The client has full knowledge of every procedure’s input and output types without ever importing the server implementation.
Where this breaks down: the types are inferred from your runtime code. If your resolver returns any or you use a loose cast anywhere in the chain, the inference fails silently and you end up with any on the client. The guarantees are only as strong as your resolver implementations.
Router Design Patterns
Flat routers become unmaintainable fast. The production pattern is to split routers by domain and merge them into a root router.
// server/routers/users.ts
export const usersRouter = t.router({
list: t.procedure
.input(z.object({ cursor: z.string().optional(), limit: z.number().min(1).max(100).default(20) }))
.query(async ({ input }) => {
const users = await db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: "desc" },
});
const hasMore = users.length > input.limit;
return {
items: hasMore ? users.slice(0, -1) : users,
nextCursor: hasMore ? users[users.length - 2].id : undefined,
};
}),
});
// server/routers/posts.ts
export const postsRouter = t.router({
byAuthor: t.procedure
.input(z.object({ authorId: z.string() }))
.query(async ({ input }) => {
return db.post.findMany({ where: { authorId: input.authorId } });
}),
});
// server/router.ts
export const appRouter = t.router({
users: usersRouter,
posts: postsRouter,
});
Nested routers compose cleanly. On the client, trpc.users.list.useQuery(...) and trpc.posts.byAuthor.useQuery(...) both have full type inference. The namespace structure matches the router structure, which makes discovery predictable.
Middleware and Context
Context is how you thread authentication, database connections, and request-scoped state through procedures without passing them as explicit arguments. You create context in the HTTP adapter and tRPC injects it into every procedure.
// server/context.ts
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import { verifyJwt } from "./auth";
export async function createContext({ req }: CreateNextContextOptions) {
const token = req.headers.authorization?.split(" ")[1];
const user = token ? await verifyJwt(token) : null;
return { user, db };
}
export type Context = Awaited<ReturnType<typeof createContext>>;
Middleware lets you enforce invariants before procedures run. The canonical pattern is a protected procedure base that rejects unauthenticated requests once, so individual procedures never need to check ctx.user:
const t = initTRPC.context<Context>().create();
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
// Re-exported as a procedure base
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthenticated);
Role-based access follows the same pattern. A middleware that checks ctx.user.role and throws FORBIDDEN can be composed with protectedProcedure to create an adminProcedure. Middleware chains are composable without creating an inheritance hierarchy.
One thing to watch: middleware runs on every request, including health checks and batch requests. Keep context initialization cheap. If your context eagerly opens a database connection, you will pay that cost even when the procedure does not need one. Prefer lazy initialization:
export async function createContext({ req }: CreateNextContextOptions) {
return {
user: null as User | null,
get db() { return getDbConnection(); }, // lazy singleton
req,
};
}
Error Handling
tRPC errors map to HTTP status codes through a defined set of error codes. Throwing TRPCError with a known code returns the right status code and structures the error for the client.
import { TRPCError } from "@trpc/server";
const userById = t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: `User ${input.id} not found`,
});
}
return user;
});
The client receives a typed error with code, message, and optionally data. The error code maps to HTTP: NOT_FOUND becomes 404, UNAUTHORIZED becomes 401, BAD_REQUEST becomes 400, INTERNAL_SERVER_ERROR becomes 500.
For validation errors, tRPC automatically catches Zod parse failures and re-throws them as BAD_REQUEST. You do not need to handle ZodError yourself in procedures.
The pattern that matters most in production is a global error formatter. It intercepts every error before it leaves the server, letting you log, redact, and shape the error payload:
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// strip internal stack traces in production
stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
// attach Zod field-level errors for form validation
zodError:
error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
On the client, zodError.fieldErrors gives you per-field validation errors without any custom parsing logic.
Performance: Batching and Streaming
tRPC ships with request batching enabled by default when using the HTTP batch link. Multiple queries that fire in the same tick get combined into a single HTTP request. This matters for dashboards and pages that load several independent queries on mount.
// client/trpc.ts
import { httpBatchLink } from "@trpc/client";
export const trpcClient = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
maxURLLength: 2083, // stay within safe GET URL length
}),
],
});
Batching has a non-obvious failure mode: if any one query in a batch fails, the entire batch returns as an error by default. Per-query error handling still works on the client, but the network request fails atomically. For queries with different error profiles, you may want to disable batching on specific procedures or use a splitLink to route sensitive queries outside the batch.
For streaming, tRPC supports server-sent events (SSE) and subscriptions. Subscriptions require a different transport link. The practical choice for most applications is httpSubscriptionLink for SSE-based subscriptions without a full WebSocket server:
// server: subscription procedure
export const appRouter = t.router({
onNewMessage: t.procedure
.input(z.object({ channelId: z.string() }))
.subscription(async function* ({ input }) {
for await (const message of subscribeToChannel(input.channelId)) {
yield message;
}
}),
});
// client: SSE link
import { unstable_httpSubscriptionLink } from "@trpc/client";
const client = createTRPCProxyClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === "subscription",
true: unstable_httpSubscriptionLink({ url: "/api/trpc" }),
false: httpBatchLink({ url: "/api/trpc" }),
}),
],
});
Subscriptions are still marked unstable in tRPC v11. Use them, but pin your tRPC version and test upgrades carefully.
Integration with Next.js App Router
The App Router complicates tRPC integration. Server Components do not use React hooks, so trpc.*.useQuery() is not available inside them. The cleanest pattern is to create a server-side caller for Server Components and keep the React Query hooks for Client Components.
// server/caller.ts
import { createCallerFactory } from "@trpc/server";
import { appRouter } from "./router";
import { createContext } from "./context";
const createCaller = createCallerFactory(appRouter);
export async function getServerCaller() {
// In App Router, you reconstruct context without a real Request
const ctx = await createContext({ req: {} as any, res: {} as any });
return createCaller(ctx);
}
// app/users/[id]/page.tsx (Server Component)
import { getServerCaller } from "@/server/caller";
export default async function UserPage({ params }: { params: { id: string } }) {
const caller = await getServerCaller();
const user = await caller.users.byId({ id: params.id });
// user is fully typed, no useQuery needed
return <UserProfile user={user} />;
}
// app/users/[id]/edit.tsx (Client Component)
"use client";
import { trpc } from "@/client/trpc";
export function EditUser({ id }: { id: string }) {
const { data } = trpc.users.byId.useQuery({ id });
const update = trpc.users.update.useMutation();
// ...
}
The server caller bypasses HTTP entirely: it calls the procedure function directly. This means you get type safety without any network overhead for data that is already available at render time on the server.
The awkward part of this pattern: the context creation for server callers needs to handle missing req/res gracefully. If your context reads cookies or headers from the request, you need to adapt it to use Next.js’s cookies() and headers() APIs instead when running in a Server Component context.
tRPC vs REST+OpenAPI vs GraphQL+Codegen
| Dimension | tRPC | REST + OpenAPI | GraphQL + Codegen |
|---|---|---|---|
| Type safety source | Inferred from router | Generated from spec | Generated from schema |
| Code generation required | No | Yes | Yes |
| Schema as source of truth | No (types are) | Yes | Yes |
| External client support | Poor | Excellent | Good |
| Incremental field selection | No | No | Yes |
| Subscriptions | SSE / WebSocket | Manual | Built-in (WS) |
| Learning curve | Low (if TS-native) | Low | Medium |
| Multi-language clients | No | Yes | Yes |
| N+1 problem tooling | None (use DataLoader) | None | DataLoader / dataloader |
| Monorepo requirement | Soft (works best) | None | None |
| Bundle size (client) | ~10KB | Varies | ~30KB+ |
The honest summary: tRPC wins on developer speed in TypeScript-only monorepos. You write a procedure, call it, and types are synchronized instantly. There is no spec to maintain, no generation step to run, and no drift between the spec and the implementation.
REST+OpenAPI wins when external clients matter. If your API needs to be consumed by mobile clients, third-party integrators, or teams writing in Go or Python, tRPC gives them nothing. OpenAPI has ecosystem-wide tooling. tRPC does not.
GraphQL wins when consumers need to select exactly which fields they want. tRPC returns whatever the resolver returns. If your resolver returns a 50-field object and the client needs 3 fields, you are over-fetching. With GraphQL, the client specifies the selection set. With tRPC, you need either separate lean procedures or accept the overfetch.
When tRPC Is the Wrong Choice
You have external API consumers. External clients cannot import your router type. They need a language-agnostic contract: an OpenAPI spec or a GraphQL schema. tRPC gives them neither. If public API access is a requirement now or in the foreseeable future, build a REST API with OpenAPI from the start.
Your team is not TypeScript-native. tRPC’s value is entirely in the type system. A team writing JavaScript without strict types, or mixing languages across services, gets nothing from tRPC. The RPC abstraction is fine but not remarkable without the inference.
You need field-level access control or partial responses. GraphQL’s resolver-per-field model makes it natural to apply authorization at the field level. tRPC procedures return the whole output or nothing. You can work around this by creating procedure variants, but the ergonomics are worse.
Your procedures are coarse-grained and rarely change. If your API surface is small, stable, and already documented, the setup cost of tRPC is not worth it compared to a few well-typed fetch wrappers with Zod parsing at the boundary.
You are building microservices with separate deployment cycles. tRPC’s type sharing requires that both sides of the API boundary can be updated together. In a microservices setup where the server and client deploy independently, you need a stable, versioned contract. A change to the server’s output type should not silently break a client that has not deployed yet.
Production Considerations
Versioning. tRPC has no built-in versioning mechanism. If you need to introduce a breaking change, you have two options: add a new procedure alongside the old one and deprecate, or version the entire router by mounting it at a different path. The monorepo constraint helps here because you can update callers in the same commit, but if you have multiple front-end applications consuming the same tRPC server, coordinating breaking changes across repos becomes painful fast.
Observability. tRPC procedures do not expose HTTP route names in the conventional sense. All batch requests hit a single endpoint. Your APM tool will show one busy route instead of per-procedure breakdowns. Instrument procedures directly with middleware:
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log({ path, type, duration, ok: result.ok });
return result;
});
The path in middleware is the dotted procedure path (users.byId), not an HTTP path. Log it, trace it, and add it to your error context. Without this, debugging production issues is much harder.
Input size limits. tRPC sends query inputs as URL-encoded JSON for GET requests. Large inputs can exceed browser and proxy URL length limits. The maxURLLength option on httpBatchLink controls when the client falls back to POST. Set it to 2083 bytes (the IE limit, and a safe floor for most proxies) or lower if you control all clients.
Rate limiting. Because all tRPC requests go through one or a small number of handlers, you need rate limiting at the procedure level, not just at the route level. Use middleware to read a rate limit key from context and check against a Redis counter. Blanket rate limiting at the HTTP layer helps against abuse but does not give you per-endpoint granularity.
Testing. The server caller pattern used for Server Components is also the right pattern for integration tests. Call procedures directly without spinning up an HTTP server:
import { createCallerFactory } from "@trpc/server";
import { appRouter } from "./router";
const createCaller = createCallerFactory(appRouter);
test("users.byId returns user when found", async () => {
const caller = createCaller({ user: { id: "test-user", role: "user" }, db: testDb });
const user = await caller.users.byId({ id: "existing-id" });
expect(user.id).toBe("existing-id");
});
test("users.byId throws NOT_FOUND for missing user", async () => {
const caller = createCaller({ user: { id: "test-user", role: "user" }, db: testDb });
await expect(caller.users.byId({ id: "missing-id" })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
This approach is fast, type-safe, and tests the actual business logic. Reserve HTTP-level tests for verifying that the adapter layer (Next.js route handler, Express middleware) works correctly.
The Real Constraint
tRPC is a productivity tool for TypeScript monorepos. It removes the feedback loop between changing a server response shape and updating the client. That loop is short anyway with good tooling, but tRPC makes it zero. That zero matters on a small team moving fast.
The constraint is organizational, not technical. As soon as your API needs to serve consumers you cannot update in the same commit, the shared type inference breaks down and you need a versioned contract. Plan for that transition before you need it. The cleanest path is to put your tRPC server behind a conventional HTTP gateway early, so you can expose REST or GraphQL endpoints later without rewriting the business logic.
tRPC earns its keep most clearly in the middle phase of a product: past “does this idea work” and before “external developers are integrating with us.” In that window, every second spent not maintaining a schema spec is a second spent building.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.