Mastering tRPC: End-to-End Type Safety Without GraphQL
The ultimate guide to tRPC for Senior Engineers. Compare tRPC vs GraphQL vs REST. Learn how to set up a Turborepo, shared Zod schemas, and optimistic UI updates.

The ultimate guide to tRPC for Senior Engineers. Compare tRPC vs GraphQL vs REST. Learn how to set up a Turborepo, shared Zod schemas, and optimistic UI updates.
Mastering tRPC: End-to-End Type Safety Without GraphQL
If you are a TypeScript developer, you have felt the pain:
- 2.Define a Type in the Database (Prisma/SQL).
- 4.Define a DTO in the API (NestJS/Express).
- 6.Define an Interface in the Frontend (React).
- 8.Write a fetch call.
If you change the Database column name, your Backend fails (Good). But your Frontend? It compiles fine. It ships. It crashes in production.
The "Contract" between Frontend and Backend is broken.
GraphQL solved this with Code Generation. But GraphQL is heavy. You need a schema, resolvers, codegen tools, and a massive runtime.
Enter tRPC.
tRPC allows you to import your Backend functions directly into your Frontend code, without running them. It uses TypeScript inference to create a magical, invisible bridge.
In this guide, we will build the "Holy Grail": A Monorepo where renaming a database column causes red squigglies in your React Button component 2 seconds later.
Part 1: The Architecture (Monorepo)
To use tRPC effectively, you need a Monorepo. Both your Next.js app and your Node/Bun server need access to the generic "AppRouter" type.
Structure:
apps/web: Next.js (Client)apps/server: Fastify/Express (Server)packages/api: Shared tRPC Router definition.packages/db: Prisma Schema.
Why?
Because apps/web will literally import type { AppRouter } from "@myrepo/api".
Part 2: Defining the Router
A tRPC router is just a collection of functions. We use Zod to validate inputs at the runtime edge.
typescript// packages/api/root.ts import { initTRPC } from '@trpc/server'; import { z } from 'zod'; const t = initTRPC.create(); export const appRouter = t.router({ getUser: t.procedure .input(z.string()) // Input validation .query(async ({ input, ctx }) => { // Logic inside here is typesafe! return await ctx.db.user.findUnique({ where: { id: input } }); }), createUser: t.procedure .input(z.object({ name: z.string(), email: z.string().email() })) .mutation(async ({ input, ctx }) => { return await ctx.db.user.create({ data: input }); }), }); // IMPORTANT: Export only the TYPE export type AppRouter = typeof appRouter;
Part 3: The Client (The Magic)
In your Frontend, you create a vanilla React hook.
typescript// apps/web/utils/trpc.ts import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@myrepo/api'; // Pure Type Import export const trpc = createTRPCReact<AppRouter>();
Using it in a Component:
tsxexport function UserProfile({ id }: { id: string }) { // 1. "getUser" autocompletes! const { data, isLoading, error } = trpc.getUser.useQuery(id); if (isLoading) return <div>Loading...</div>; if (!data) return <div>Not Found</div>; // 2. "data.email" is strictly typed! // If you rename 'email' to 'emailAddress' in the backend, this line errors. return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> </div> ); }
Part 4: Why not GraphQL?
I used GraphQL for 5 years. I loved it. But for internal tools or single-team projects, it is overkill.
| Feature | GraphQL | tRPC |
|---|---|---|
| Schema | SDL (String) | TypeScript (Code) |
| Validation | Built-in | Zod (Runtime) |
| Codegen | Required (graphql-codegen) | None! (Inference) |
| Payload | Heavy (Query String) | Light (JSON Array) |
| Caching | Normalized Cache (Apollo) | React Query |
The Killer Feature: With tRPC, "Go to Definition" on the frontend taking you directly to the backend function. No jumping through schemas.
Part 5: Optimistic Updates
Because tRPC wraps TanStack Query (React Query), we get powerful cache management for free.
Let's say we mutate a "Like" button.
typescriptconst utils = trpc.useContext(); const mutation = trpc.post.like.useMutation({ onMutate: async (newLike) => { // 1. Cancel outgoing fetches await utils.post.get.cancel(); // 2. Snapshot previous value const prevData = utils.post.get.getData(); // 3. Optimistically update local cache utils.post.get.setData(undefined, (old) => ({ ...old, likes: old.likes + 1, })); return { prevData }; }, onError: (err, newLike, context) => { // 4. Rollback on error utils.post.get.setData(undefined, context.prevData); }, onSettled: () => { // 5. Refetch to ensure true consistency utils.post.get.invalidate(); } });
This code is verbose, but it guarantees a "Snap" UI where the number updates instantly, even on 3G.
Part 6: tRPC in Next.js Server Components
With Next.js App Router, we can skip the HTTP layer entirely for Server Components. This is called the "Server Caller".
typescript// apps/web/app/page.tsx import { appRouter } from '@myrepo/api'; import { db } from '@myrepo/db'; export default async function Page() { // CREATE THE CALLER const serverClient = appRouter.createCaller({ db, session: null }); // CALL DIRECTLY (No HTTP fetch!) const user = await serverClient.getUser("user-123"); return <div>{user.name}</div>; }
This is huge. You reuse the exact same logic (validation, authorization, database calls) for both your Client API (fetch) and your Server Components (function call).
Part 7: When NOT to use tRPC
tRPC is not a silver bullet.
- 2.Public APIs: If you are building an API for 3rd party developers (like Stripe), use REST or OpenAPI. They don't have your TypeScript types.
- 4.Using other languages: If your backend is Go/Rust, tRPC acts as a wrapper, but you lose the "End-to-End" inference benefit.
- 6.Microservices: If Team A owns the Backend and Team B owns the Frontend and they are in different repos, tRPC is hard.
tRPC is for Monorepos. Or tightly coupled full-stack teams.
Conclusion: The "Zero-API" Mindset
tRPC allows you to stop thinking about "API endpoints", "HTTP methods", "Status Codes", and "Serialization". You just write functions. You call functions.
If you are a solo developer or a small startup speed-running to MVP, there is no stack faster than T3 (Tailwind, tRPC, TypeScript).
It removes an entire class of bugs (Schema desync) and allows you to refactor with confidence.
Resources
About the Author: Sachin Sharma ships production apps with the T3 Stack. He believes that Type Safety is the single biggest productivity booster in modern web development.

Running Llama 3 on Mobile: The Ultimate Guide to Local LLMs with Flutter
The future of AI is offline. In this 4,500-word tutorial, we compile Llama 3 to run on iOS and Android using MLC LLM and Flutter. We benchmark token speed, memory usage, and battery drain.

Mobile DevOps at Scale: Automating Flutter Releases with Fastlane & GitHub Actions
Stop manually archiving Xcode builds. In this 5,000-word handbook, we build a complete CI/CD pipeline that runs tests, signs binaries, and uploads to the App Store and Play Store on every git tag.