Why I Switched from REST to tRPC (And When I Wouldn't)
I built REST APIs for three years. I got pretty good at it. I had my patterns down - Express with Zod validation, OpenAPI specs generated from schemas, Axios on the frontend with typed response interfaces. It worked.
Then a colleague introduced me to tRPC on a side project, and within a week I was annoyed at every REST API I’d ever built.
The thing that got me wasn’t the performance or the architecture. It was the type safety. With tRPC, when I change a field name on my backend, my frontend immediately shows a TypeScript error. No code generation step. No schema syncing. No forgetting to update the API client. It just works.
Here’s what the switch looked like in practice, and why I still use REST for some things.
What tRPC Actually Is
tRPC is a TypeScript-first RPC (Remote Procedure Call) framework. Instead of defining REST endpoints with URLs, HTTP methods, and JSON schemas, you define procedures - basically typed functions that the frontend can call directly.
A simple tRPC router looks like this:
import { router, publicProcedure } from './trpc';
import { z } from 'zod';
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
return user;
}),
createPost: publicProcedure
.input(z.object({
title: z.string().min(1),
content: z.string()
}))
.mutation(async ({ input }) => {
return db.post.create({ data: input });
}),
});
On the frontend:
const user = trpc.getUser.useQuery({ id: '123' });
// user.data is fully typed - TypeScript knows exactly what fields exist
No fetch calls. No URL construction. No response type definitions. The frontend knows the input and output types of every procedure because TypeScript infers them from the backend code.
What Changed for Me
Refactoring became fearless. In a REST setup, renaming a field means updating the backend, the API documentation, and every frontend call that uses that field. Miss one and you get a runtime error that might not surface until production. With tRPC, renaming a field gives you compile-time errors everywhere it’s used. I can refactor with confidence.
API documentation became unnecessary. I don’t mean that arrogantly - I mean the type system IS the documentation. When a frontend developer hovers over a tRPC procedure call, they see the exact input and output types. No Swagger UI needed. No stale API docs that haven’t been updated in months.
Validation came free. Zod schemas define both the runtime validation and the TypeScript types. Define once, used everywhere. In my REST setup, I had Zod validation on the backend and separate TypeScript interfaces on the frontend. Duplication gone.
Development speed increased noticeably. Adding a new feature that involves backend and frontend changes went from a multi-file, multi-step process to something much more streamlined. I estimate my development time for full-stack features dropped by about 30%.
The Setup I Use
My current stack for tRPC projects:
- Backend: tRPC with Fastify adapter, Prisma for database access, Zod for validation
- Frontend: Next.js with tRPC React Query integration
- Deployment: Vercel for the frontend, Railway for the backend
The tRPC + React Query integration is particularly good. You get all of React Query’s caching, refetching, and optimistic update features with full type safety. The tRPC documentation has solid examples for this setup.
For authentication, I use tRPC middleware:
const protectedProcedure = publicProcedure.use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { user: ctx.session.user } });
});
Any procedure defined with protectedProcedure instead of publicProcedure requires authentication. Clean, type-safe, and consistent.
When I Still Use REST
tRPC isn’t the right choice for everything. Here’s when I stick with REST:
Public APIs consumed by external clients. tRPC requires both the client and server to share TypeScript types. If your API is consumed by mobile apps, third-party integrations, or non-TypeScript clients, REST with OpenAPI is the better choice.
Webhook endpoints. When external services need to call your API (payment processors, CRM systems, etc.), they’re going to send HTTP requests to URLs. tRPC endpoints can handle this, but REST is more natural.
Simple CRUD with no frontend. If I’m building a pure backend service that other services consume, REST is simpler and more interoperable. tRPC’s benefits are strongest when you control both ends of the connection.
Teams with non-TypeScript components. If your team has Python microservices, a Go backend, or any non-TypeScript component that needs to communicate with your API, REST gives you language-agnostic interoperability. Working with firms like Team400.ai on projects that span multiple technology stacks has reminded me that not everything lives in the TypeScript ecosystem, and API design needs to account for that reality.
Migration Tips
If you’re considering switching an existing REST API to tRPC:
- Don’t rewrite everything at once. Start with new features and migrate existing endpoints gradually.
- Keep your REST API running alongside tRPC during migration. tRPC and Express/Fastify coexist happily.
- Start with queries (GET equivalents) before mutations (POST/PUT/DELETE equivalents). Queries are simpler and lower-risk.
- Use Zod schemas you already have. If you’re already validating with Zod on your REST endpoints, those schemas can be reused directly in tRPC procedures.
The Honest Assessment
tRPC has made me a faster, more confident full-stack developer. The type safety catches bugs that would have reached production in my REST setup. The development experience - autocomplete on API calls, instant feedback on type mismatches, no schema syncing - is genuinely better.
But it’s not magic. It’s a tool with a specific sweet spot: TypeScript full-stack applications where you control both the client and the server. Outside that sweet spot, REST remains the sensible default.
If you’re building a Next.js app with a TypeScript backend and you’re still writing fetch calls with manual type annotations, give tRPC a serious look. The initial learning curve is about a day. The payoff lasts the lifetime of the project.