tRPC gives you full end-to-end type safety between a TypeScript frontend and backend with zero code generation, and that "zero codegen" part is the actual innovation — GraphQL and OpenAPI both need a build step to get types this precise.
tRPC lets you define server-side procedures once, then call them from the client with full TypeScript autocomplete and type checking — no schema file, no generated client, no REST endpoint documentation to keep in sync. It works by exporting your router's TypeScript type and importing it on the client; the type system does the rest.
Why tRPC Matters (and When to Skip It)
The core pain tRPC solves is API contract drift — a REST endpoint's actual response shape and its documented shape diverge over time unless you invest in OpenAPI generation and keep it maintained. tRPC makes drift structurally impossible: if the server function's return type changes, the client call site shows a TypeScript error immediately, not a runtime surprise.
Skip tRPC the moment your client isn't TypeScript — it fundamentally requires sharing types between a TS backend and TS frontend, usually in the same monorepo. For a public API consumed by third parties, or a mobile app in Swift/Kotlin, REST or GraphQL with a real schema is the right choice.
Getting Started with tRPC
Define a router on the server:
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.users.findById(input.id);
}),
createPost: t.procedure
.input(z.object({ title: z.string().min(1), body: z.string() }))
.mutation(async ({ input }) => {
return db.posts.create(input);
}),
});
export type AppRouter = typeof appRouter;
Call it from the client with full type inference:
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/router";
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: "http://localhost:3000/trpc" })],
});
const user = await client.getUser.query({ id: "1" }); // fully typed return value
Core tRPC Concepts Every Developer Should Know
Zod schemas double as runtime validation and TypeScript types. The .input(z.object({...})) call both validates incoming requests at runtime and infers the input type for the client — one source of truth for both concerns, the same pattern Fastify's JSON schemas achieve for a different type system.
Queries and mutations map directly to GET and POST semantics, but you don't write HTTP verbs or URLs — tRPC handles the transport layer, letting you think purely in terms of function calls.
Middleware composes onto procedures, similar to Express/Hono middleware but type-aware — a protected procedure can narrow the context type to guarantee an authenticated user is present:
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, user: ctx.user } }); // ctx.user now non-nullable downstream
});
React Query integration is built in, via @trpc/react-query — you get typed hooks (useQuery, useMutation) with the same caching, refetching, and loading-state behavior React Query already provides, wired directly to your router's types.
Common tRPC Mistakes and How to Fix Them
Mistake 1: using tRPC for a public, third-party-consumed API. Without a real schema (OpenAPI, GraphQL SDL), non-TypeScript consumers have nothing to generate a client from. Fix: use tRPC internally, and expose a separate REST or GraphQL layer for external consumers if needed.
Mistake 2: skipping Zod validation on inputs "since TypeScript already checks it." TypeScript types are erased at runtime — a malicious or malformed request bypasses compile-time checks entirely. Fix: always validate with .input(zodSchema); the type safety only extends to trusted TypeScript callers, not the actual network boundary.
Mistake 3: giant single-file routers. As the API grows, one massive appRouter becomes unwieldy. Fix: split into feature-based sub-routers and merge them with t.mergeRouters() or nested router objects.
When Should You Use tRPC Instead of REST or GraphQL?
Use tRPC for full-stack TypeScript monorepos (Next.js apps, T3 stack projects) where the same team owns both client and server. Use REST or GraphQL when you need a language-agnostic contract for external consumers, mobile clients in other languages, or public API documentation.
tRPC in Production
Pair tRPC with httpBatchLink to automatically batch multiple queries fired in the same tick into a single HTTP request — meaningful for pages that fire several independent queries on load. Also structure routers by feature/domain from the start (users, posts, billing) rather than one flat file, since tRPC routers tend to grow quickly once a team adopts them for everything.
If your frontend and backend are both TypeScript in the same repo, try tRPC on the next new feature before reaching for a REST endpoint — the type-safety payoff shows up almost immediately in fewer client/server mismatch bugs.