If you're still writing fetch('/api/users') in a Next.js app, you're working harder than you need to. Here's why tRPC changes everything.

I built REST APIs for three years. I wrote the routes, the types, the fetch wrappers, the Zod schemas on both ends. I thought that was just the cost of doing business.
Then I used tRPC on a real project and realized I had been paying a tax that didn't need to exist.
Every REST API in a Next.js app has a hidden cost that compounds over time. You write a route handler — let's say GET /api/posts. Then you go to your component and write the fetch. Then you write the TypeScript type for the response. Then you realize the type drifted from what the API actually returns. Then you spend 20 minutes debugging why post.author is undefined.
This is the network boundary tax. You're maintaining two representations of the same data — one on the server, one on the client — and manually keeping them in sync.
// server — route handler
export async function GET() {
const posts = await db.post.findMany({ include: { author: true } });
return Response.json(posts);
}
// client — you manually duplicate the type
type Post = {
id: string;
title: string;
author: { name: string }; // hope this stays in sync
};
const res = await fetch("/api/posts");
const posts: Post[] = await res.json();You can't refactor the server type and have TypeScript catch the mismatch on the client. You can't cmd+click a function call and jump to the server implementation. You're flying partially blind.
tRPC isn't a new protocol. There's no codegen, no schema file, no .proto format. It's just TypeScript — your server functions become directly callable from your client, with full type inference end-to-end.
// server — one place, one type
const postsRouter = router({
list: publicProcedure.query(async () => {
return db.post.findMany({ include: { author: true } });
}),
});
// client — the type flows automatically
const posts = await trpc.posts.list.query();
// ^? Post[] — inferred from the actual return type aboveIf you change the server return type, TypeScript immediately errors on every caller. If you remove a field, every component that touches it breaks at compile time. The feedback loop shrinks from "runtime bug in production" to "red squiggly in your editor."
REST APIs are already painful on the client. In SSR, they're even worse.
The typical Next.js App Router pattern with REST looks like this:
// app/posts/page.tsx
export default async function PostsPage() {
const res = await fetch("http://localhost:3000/api/posts", {
cache: "no-store",
});
const posts = await res.json(); // untyped, full round-trip, localhost URL hardcoded
}You're making an HTTP request from your server... to your own server. You're serializing and deserializing data that's already in the same Node.js process. You're paying network overhead for nothing. And you're hardcoding localhost:3000 which breaks in production unless you configure NEXT_PUBLIC_URL everywhere.
With tRPC, SSR is a direct function call:
// app/posts/page.tsx
import { createCaller } from "@/server/trpc";
import { createContext } from "@/server/context";
export default async function PostsPage() {
const trpc = createCaller(await createContext());
const posts = await trpc.posts.list(); // direct call, no HTTP, full types
}No HTTP round-trip. No URL. No fetch. No JSON serialization. The data goes directly from your database query to your component, fully typed, with zero overhead.
The moment I became a convert was during a refactor. We needed to add pagination to a posts endpoint — change the response from Post[] to { posts: Post[], total: number, nextCursor: string | null }.
With REST, the process was:
fetch('/api/posts') call across the codebaseWith tRPC, the process was:
One was archaeology. The other was engineering.
The most common pushback: "I can't use tRPC because I need a REST API for mobile clients / third-party integrations."
Fair. tRPC isn't for public APIs. It's for the boundary between your own server and your own client. If you need both, use both — tRPC for your Next.js app, REST for the public surface. They can coexist.
But most apps never need a public API. They're serving their own frontend. And for that use case, writing REST is choosing complexity you didn't have to choose.
Nothing you'll miss.
fetch under the hood, and React Query handles caching on the client side better than HTTP cache headers anyway.People assume tRPC is complex to set up. It's four files:
src/server/trpc.ts — create the tRPC instance
src/server/router.ts — define your procedures
src/server/context.ts — request context (auth, db, etc.)
src/app/api/trpc/[...trpc]/route.ts — the single HTTP handlerThat's it. No codegen. No build step. No schema files. Just TypeScript talking to TypeScript.
REST made sense when your server and client were different teams, different codebases, different languages. When it's all TypeScript in a monorepo, maintaining the boundary manually is friction you invented for yourself.
tRPC removes the boundary. The type is defined once. The function is called directly. The compiler catches the drift before it reaches production.
That's not a marginal improvement. That's a different way of thinking about the server-client relationship — and once you see it, the old way feels like unnecessary work.