Anas.dev
All posts
Writing

Why tRPC Makes REST Feel Outdated in SSR Apps

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.

·6 min read
Why tRPC Makes REST Feel Outdated in SSR Apps

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.


The tax you're paying with REST

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.

ts
// 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.


What tRPC actually is

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.

ts
// 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 above

If 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."


Where this matters most — SSR

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:

ts
// 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:

ts
// 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 refactor that sold me

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:

  1. Update the route handler
  2. Find every fetch('/api/posts') call across the codebase
  3. Update each type manually
  4. Run the app and pray I caught them all
  5. Discover at runtime that I missed one in a rarely-visited page

With tRPC, the process was:

  1. Update the procedure return type
  2. Watch TypeScript instantly underline every caller that wasn't handling the new shape
  3. Fix them all
  4. Done

One was archaeology. The other was engineering.


"But I need a public API"

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.


What you actually give up

Nothing you'll miss.


The actual setup cost

People assume tRPC is complex to set up. It's four files:

code
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 handler

That's it. No codegen. No build step. No schema files. Just TypeScript talking to TypeScript.


The bottom line

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.