Why We Moved From REST to tRPC in Our Next.js App
After six months of REST fatigue, we migrated our internal dashboard to tRPC. Here’s what we learned — and what we’d do differently.

We had a problem familiar to most product teams: a Next.js frontend making REST calls to a Node backend, with TypeScript on both sides but no type sharing between them. Every API change required updating the client manually, and runtime mismatches were catching us in production.
What Was Going Wrong
Our API surface had grown to around 40 endpoints over 18 months. The pattern was always the same: define the route in Express, write a fetch wrapper on the client, manually keep the types in sync. When a backend engineer changed a response shape, the frontend wouldn’t know until a QA pass caught it — or a user did.
The Type Gap Problem
TypeScript gives you safety within a codebase. The moment you cross a network boundary, you lose it. You can write as SomeType on your fetch response, but that’s just telling the compiler to trust you. It doesn’t actually verify anything at runtime.
What We Tried First
We evaluated OpenAPI code generation, Zod-based validators, and just sharing a types package. All of them added friction: generated code that needed re-running after every schema change, or a shared package that lagged behind actual API behaviour.
Why tRPC Fit Our Stack
tRPC works by exposing server-side procedures as a TypeScript interface your client imports directly. There’s no schema file, no code generation step, no HTTP verbs to argue about. The server defines the contract; the client consumes it with full type inference.
// server
export const appRouter = router({
post: router({
list: publicProcedure
.input(z.object({ limit: z.number().default(10) }))
.query(async ({ input }) => {
return db.post.findMany({ take: input.limit })
}),
}),
})
// client — types flow automatically
const { data } = trpc.post.list.useQuery({ limit: 5 })
Integration With Next.js App Router
The App Router complicates things slightly: server components can call procedures directly without a network hop, while client components go through the standard HTTP transport. We ended up with a thin helper that detects the rendering context and routes accordingly.
What the Migration Looked Like
We didn’t do a big bang rewrite. Over three sprints, we moved one feature area at a time — starting with the least-trafficked admin screens and working toward the core dashboard. Each migration was a straight swap: delete the fetch wrapper, replace with the tRPC hook, remove the manually typed response interface.
The Parts That Took Longer
File uploads don’t fit the tRPC model well. We kept those as plain multipart POST endpoints and wrapped them in a thin utility. Pagination cursor logic also needed care — tRPC doesn’t have opinions on pagination, so we standardised on a cursor + limit convention across all list procedures.
Results After Three Months
Type errors caught at compile time that would have previously shipped: 12. Time spent on “why is the client crashing on this field”: near zero. The biggest win wasn’t the bugs we caught — it was the confidence to refactor backend logic without anxiety about what it would break on the frontend.
If your team already has TypeScript on both sides of a Next.js app, tRPC is the lowest-friction path to end-to-end type safety we’ve found.
