Building full-stack web applications with TypeScript has always promised a unified developer experience. However, for a long time, the boundary between the frontend and backend remained a pain point. In my recent project, I started with the standard approach: a Next.js frontend communicating with REST API endpoints validated using Zod.
While REST works well and remains an industry standard, maintaining type synchronization between my API routes and React components quickly became tedious. That is when I decided to migrate the project to tRPC. Here is why that decision radically improved my development speed and peace of mind.
The Friction with REST
When building a TypeScript application with traditional REST endpoints, you typically follow one of two paths:
- Manual Type Duplication: You define TypeScript interfaces for your request and response payloads on the server, and then manually recreate or export those types for the client.
- Code Generation: You write OpenAPI specs or Zod schemas, then run build scripts to generate client SDKs or type declarations.
Both approaches introduced friction in my workflow. Every time I modified a database model or an API payload, I had to update types in multiple places or remember to run a code generation script. If I forgot, the client broke silently at runtime.
Why tRPC Changed Everything
tRPC eliminates the build step entirely. By taking advantage of TypeScript's infer capabilities, tRPC imports the type definitions directly from your backend router into your frontend client code—without bundling any server logic into the client bundle.
Here is a quick look at how simple defining a procedure on the server is:
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 await prisma.user.findUnique({ where: { id: input.id } });
}),
});
export type AppRouter = typeof appRouter;
On the client side, fetching this data with full autocompletion and type checking requires zero manual interface declarations:
import { trpc } from '../utils/trpc';
export function UserProfile({ userId }: { userId: string }) {
const { data, isLoading } = trpc.getUser.useQuery({ id: userId });
if (isLoading) return <div>Loading...</div>;
return <div>Welcome, {data?.name}</div>;
}
Key Takeaways From the Migration
1. Instant Refactoring Confidence
If I rename a field in my database schema or change a Zod validator on the server, TypeScript immediately flags every broken component on the frontend in real time. Refactoring went from a stressful manual search to pressing F2 in VS Code.
2. Zero CodeGen Overhead
Unlike GraphQL or OpenAPI, there are no watchers, extra build scripts, or generated .d.ts files to check into Git. The server router type is the API contract.
3. Built-in React Query Integration
Under the hood, @trpc/react-query wraps TanStack Query. This meant I retained all the powerful caching, optimistic updates, and background refetching features I was already used to, but with zero HTTP client boilerplate.
Is REST Dead for Me?
Not at all. REST is still my go-to choice when building public APIs intended for third-party consumption or when working with heterogeneous tech stacks (like a Python backend paired with a React frontend). But when I control both the client and server in a monorepo or full-stack TypeScript framework like Next.js, tRPC is now my default choice.
Final Thoughts
Moving to tRPC in my last project saved me countless hours of writing boilerplate API wrappers and debugging runtime type mismatches. If you are building a full-stack TypeScript application, I highly recommend giving it a try.