
tRPC: Full-Stack Type Safety Without API Specs
Every REST API I built had type mismatches between frontend and backend. tRPC eliminated the gap entirely—no API specs, no codegen, just types that flow.

Every REST API I built had type mismatches between frontend and backend. tRPC eliminated the gap entirely—no API specs, no codegen, just types that flow.
Why did Facebook ditch REST API? The charm of picking only what you want with GraphQL, and its fatal flaws (Caching, N+1 Problem).

Backend: 'Done.' Frontend: 'How to use?'. Automate this conversation with Swagger.

Stop using 'any'. How to build reusable, type-safe components using Generics. Advanced patterns with extends, keyof, and infer.

Fixing the crash caused by props coming through as undefined from the parent component.

I was building a side project solo. Express backend, Next.js frontend. Both in TypeScript. The code was clean. I thought I had things under control.
One afternoon I refactored the user profile API. The field user_name bothered me—inconsistent with the rest of the codebase. I renamed it to username. Updated the backend. Tests passed. Deployed.
The next morning, Slack was full of error alerts.
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
The frontend was calling user.user_name.toUpperCase(). The backend was sending username. So user_name was undefined everywhere, and every user trying to view a profile got a broken page.
I was using TypeScript. Why didn't I get a compile error?
Because the backend User type and the frontend User type were defined in separate files. When I changed the backend type, TypeScript had no way to know the frontend type was now wrong. The two type definitions lived in completely different worlds. There was no connection between them.
That bug sent me looking for a way to make the type system actually span the client-server boundary. That's when I found tRPC.
REST APIs communicate over HTTP with JSON. That's not the problem. The problem is that type information doesn't travel across HTTP.
Here's what the situation looks like in practice:
// Backend (Express)
interface User {
id: string;
username: string; // renamed from user_name
email: string;
createdAt: Date;
}
app.get('/api/user/:id', async (req, res) => {
const user: User = await db.users.findById(req.params.id);
res.json(user);
});
// Frontend (Next.js) — separate file, type copied by hand
interface User {
id: string;
user_name: string; // still the old name
email: string;
createdAt: string; // Date becomes string through JSON
}
const response = await fetch(`/api/user/${userId}`);
const user: User = await response.json(); // this type assertion is a lie
fetch().json() returns any. The moment you cast it with as User, TypeScript stops checking. It trusts you. If the backend sends something different, TypeScript has nothing to say about it. The crash waits until runtime.
Think of it like sending letters between offices in different buildings. The sender writes a letter and mails it. The recipient assumes the format based on previous experience. If the sender changes the format without telling the recipient, the reader misinterprets the letter—but only when they actually try to use the information.
API specs (OpenAPI, etc.) are supposed to be the agreed format. But specs require manual maintenance. Change the backend code, update the spec, update the frontend types. Three places. All of them have to stay in sync. Easy to miss one.
tRPC is a TypeScript RPC (Remote Procedure Call) framework. The core idea is simple:
Export the TypeScript types from your backend router. Import them directly in your frontend client. Types flow from source code to source code, with no intermediate artifact.
HTTP requests and JSON responses still happen. But TypeScript inference flows through them. Define a router on the backend, and the exact return types are available in your frontend code—with full autocomplete, hover docs, and compile-time errors when something doesn't match.
No API spec to maintain. No codegen to run. The types live in the actual running code.
The analogy that clicked for me: REST is like communicating by interdepartmental memo. You agree on a form format, fill it out, send it through the mail room, and the other department reads it according to their copy of the format. If your department changes the form without updating theirs, things go wrong when they try to use the data.
tRPC is a direct internal phone line. The frontend developer writes code that looks like calling a backend function directly. TypeScript tells you in real time what arguments the function accepts and what it returns. If the backend signature changes, the frontend immediately shows a red underline. The misuse is caught before you even open a browser.
Here's the minimal setup for tRPC in a Next.js fullstack app.
// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// src/server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
// query: read-only procedure (like GET)
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.users.findById(input.id);
return {
id: user.id,
username: user.username, // change this → instant frontend error
email: user.email,
createdAt: user.createdAt,
};
}),
// mutation: write procedure (like POST/PUT/DELETE)
updateUsername: publicProcedure
.input(z.object({
id: z.string(),
username: z.string().min(3).max(20),
}))
.mutation(async ({ input }) => {
return db.users.update(input.id, { username: input.username });
}),
});
// src/server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
export const appRouter = router({
user: userRouter,
});
// Export this type — the frontend imports it
export type AppRouter = typeof appRouter;
// src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
// AppRouter type flows in here
export const trpc = createTRPCReact<AppRouter>();
// src/components/UserProfile.tsx
import { trpc } from '@/lib/trpc';
function UserProfile({ userId }: { userId: string }) {
// Full autocomplete: trpc.user.getById.useQuery
// TypeScript knows exactly what `data` contains
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId });
if (isLoading) return <div>Loading...</div>;
if (!user) return null;
// user.username — type is inferred from backend return type
// user.user_name — compile error: property does not exist
return (
<div>
<h2>{user.username}</h2>
<p>{user.email}</p>
</div>
);
}
If I rename username to displayName in the backend router, user.username in UserProfile immediately underlines in red. The error appears before I reload the browser. The problem that sent me error alerts on a Monday morning would have been caught on the previous Friday afternoon.
Before committing to tRPC, I seriously considered GraphQL. Here's how I actually think about the tradeoffs:
| REST | GraphQL | tRPC | |
|---|---|---|---|
| Type safety | Manual maintenance | Requires codegen | Automatic via inference |
| Learning curve | Low | High | Medium |
| External API support | Full | Full | None |
| Ecosystem | Enormous | Large | Growing |
| Best team size | Any | Medium-large | Small / monorepo |
| Runtime validation | Build it yourself | Build it yourself | Zod built-in |
REST's weakness is the type gap. You can patch it with OpenAPI + codegen, but that adds tooling complexity and a spec file that needs manual upkeep.
GraphQL's strength is flexible querying—clients request exactly what they need. This shines when multiple teams or multiple clients (web, mobile, third-party) consume the same API differently. Its cost is high: schema definition, resolver layer, codegen setup. For a solo developer shipping fast, it's often more infrastructure than the problem justifies.
tRPC's hard constraint: it only works in a TypeScript monorepo where frontend and backend share the same codebase. You can't use tRPC to build a public API that mobile apps or third-party services will consume. If your frontend isn't TypeScript, or if client and server live in separate repositories with no sharing plan, tRPC isn't an option.
But if you're a solo developer or a small team building a Next.js fullstack app where everything lives together? tRPC is the fastest path to type safety with the least overhead.
From direct experience, here's the decision framework:
Use tRPC when:Migrating from REST incrementally is straightforward in Next.js. Add a single /api/trpc/[trpc] endpoint. Existing /api/* routes keep working alongside it. Start tRPC for new features while leaving existing REST endpoints in place. Move endpoints over when they naturally need changes. There's no big-bang migration required—the two approaches coexist without conflict.
Using tRPC made me understand why Zod matters beyond just "TypeScript types at runtime."
tRPC is designed to pair with Zod. Every procedure gets .input(z.object({...})). This gives you two distinct layers of protection simultaneously.
Compile-time: TypeScript catches incorrect usage before the code runs. Pass the wrong shape to a procedure call, and you see an error in your editor.
Runtime: When an actual HTTP request arrives, Zod validates the input before your handler function runs. Malformed data, unexpected types, missing fields—all caught before they reach your business logic.
This matters because TypeScript types are erased at compile time. The type information doesn't exist at runtime. If you define a field as string in TypeScript but someone sends a number in the request body, JavaScript accepts it without complaint. Zod performs real validation at runtime. Since the Zod schema and the TypeScript type are defined in the same place, you get both layers of protection from a single declaration.
The analogy: TypeScript is passport control—it validates your travel plans at the planning stage. Zod is the airport security checkpoint—it verifies things in person right before you board. Either one alone provides some protection. Both together, defined in one place, cover the complete surface.
The production bug with the renamed field was a wake-up call. Not just "I should be more careful"—the lesson was that careful doesn't scale. You can't rely on remembering to update types in two places every time the backend changes. The system needs to make that impossible.
tRPC makes it structurally impossible to have silently mismatched types between client and server. The type system is the API contract. The source code is the spec. When one side changes, the other side knows immediately.
It's not the right tool for every situation. If you need a public API, REST or GraphQL is the answer. If your setup isn't a TypeScript monorepo, tRPC won't work. But for a solo developer or small team building a fullstack TypeScript app—where speed matters and maintenance overhead needs to stay low—tRPC removed an entire category of bugs from my workflow. The Monday morning error alerts stopped.
That was enough for me.