Back
Why I Switched from REST to tRPC in Production

Why I Switched from REST to tRPC in Production

End-to-end type safety and the trade-offs that come with it

May 2, 2025 · 6 min read

After maintaining a REST API with 47 endpoints and a growing TypeScript frontend, I was spending more time keeping types synchronized than building features. That's when I decided to migrate to tRPC.

The Problem with REST + TypeScript

Our REST API had manually maintained type definitions on both the client and server. Every time we added a field to a response, we had to update:

  1. The database schema
  2. The server-side response type
  3. The API documentation
  4. The client-side type definition

This four-step dance was error-prone. Types would drift apart, leading to runtime errors that TypeScript was supposed to prevent. We'd ship a feature, only to discover in production that the frontend expected a string where the backend now returned an object.

Enter tRPC

tRPC eliminates the API boundary entirely. Your server-side router definition IS the client-side type. Change a return type on the server, and your IDE immediately shows you every client-side usage that needs updating.

Here's how a basic tRPC router looks:

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 }) => {
const user = await db.user.findUnique({
where: { id: input.id },
});
return user;
}),
updateProfile: t.procedure
.input(z.object({
name: z.string().min(1),
bio: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
return db.user.update({
where: { id: ctx.userId },
data: input,
});
}),
});

And on the client, you get full type inference:

// The return type is automatically inferred from the server
const { data: user } = trpc.getUser.useQuery({ id: "123" });
// TypeScript knows user.name, user.email, etc.
const mutation = trpc.updateProfile.useMutation();
// TypeScript enforces the exact input shape
mutation.mutate({ name: "Shivam", bio: "Builder" });

The migration was surgical — we replaced endpoints one at a time, running tRPC alongside our existing REST API. We completed the migration of 47 endpoints in about three weeks.

The DX Improvement

The developer experience improvement was immediate and dramatic. Auto-complete now works across the full stack. Hover over a useQuery call and you see the exact return type, inferred all the way from the database query. Refactoring became fearless — rename a field on the server, and TypeScript catches every client reference instantly.

The Trade-offs

What We Gained

  • Zero type drift — impossible for client and server types to disagree
  • Autocomplete everywhere — the client knows every available procedure and its exact input/output shape
  • Faster iteration — removing the type synchronization step saved roughly 30% of feature development time
  • Smaller bundle — no need for axios or fetch wrappers; tRPC's client is lightweight

What We Lost

  • HTTP caching — tRPC uses POST for mutations and batched queries, complicating CDN caching
  • API discoverability — no more Swagger/OpenAPI docs for external consumers
  • Ecosystem lock-in — tRPC is TypeScript-only; if you need a mobile client in Swift or Kotlin, you'll need a separate API
  • Debugging — REST endpoints are easy to test with curl; tRPC procedures require the client

Would I Do It Again?

Absolutely — for internal, TypeScript-only applications. For public APIs or multi-language consumers, REST with generated types (like OpenAPI + code generation) remains the better choice.

The key insight isn't that tRPC is better than REST.

It's that the right abstraction depends on your consumer. Internal app with a TypeScript frontend? tRPC.

Public API with diverse clients? REST with OpenAPI. The mistake is treating this as a religious debate rather than an engineering decision with clear trade-offs.

Thank you for reading 💖