After building several production systems with both REST and tRPC, I've settled on tRPC for new projects. Here's why — and when REST is still the right call.
The problem with REST
REST is universal and well-understood. But when you're building a full-stack TypeScript app, you end up maintaining types in two places: your backend response shapes and your frontend fetch calls. Change a field on the server? You might miss updating the client. Runtime errors creep in.
Typical flow: you define an interface in types/order.ts, use it in your Express handler, then manually copy (or import) it in your React component. Add a new field? Update both. Rename something? Find and replace. It works, but it's fragile. I've lost count of bugs from type drift.
What tRPC gives you
tRPC lets you define procedures (queries and mutations) in TypeScript. The client automatically infers the types. No codegen, no OpenAPI, no manual type sync. You call trpc.orders.getById.query({ id: 123 }) and the return type is exactly what your backend returns.
That means autocomplete, refactoring safety, and fewer "undefined is not a function" at 2am. Your IDE knows the shape of every response. Change the backend, and the frontend lights up with errors until you fix the call sites. That's a feature, not a bug.
Basic setup
You define a router with procedures. Each procedure has an input validator (I use Zod) and a resolver that returns data. The client is created with a URL and automatically gets full type inference.
// Server
const appRouter = router({
getById: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return db.orders.findUnique({ where: { id: input.id } });
}),
});
// Client — full type inference
const order = await trpc.orders.getById.query({ id: 123 });
// order is typed as Order | null
Mutations work the same way. Subscriptions too, if you need real-time. The pattern is consistent.
Error handling
tRPC has a built-in error format. Throw TRPCError with a code (BAD_REQUEST, UNAUTHORIZED, etc.) and the client gets a structured error. No more parsing HTTP status codes and guessing. You can add custom error types for domain-specific failures (e.g. INSUFFICIENT_STOCK).
When REST still makes sense
tRPC is best when your frontend and backend share a codebase or at least a monorepo. If you're building a public API for third-party consumers, REST (or GraphQL) is the right choice.
- Public APIs — third parties use any language. They need OpenAPI/Swagger, not a TypeScript client.
- Mobile apps — if your mobile team uses Swift/Kotlin, they can't use tRPC's TypeScript client. You'd need to maintain a separate REST or GraphQL layer.
- Webhooks — external systems call you. They expect REST.
For internal tools, dashboards, and Next.js apps where the API is consumed by your own frontend, tRPC is hard to beat.
Production experience
I've used tRPC on TraceFood, British Automations, and internal tools. The biggest win: schema changes are safe. Rename a field, add a required param, or change a return type — TypeScript catches every broken call site before deploy. For systems that evolve quickly, that's invaluable.
One gotcha: tRPC uses HTTP under the hood (or WebSockets for subscriptions). For server-side calls in Next.js, use the server-side client — it doesn't make a network request, it calls the procedures directly. That keeps things fast and avoids CORS.
Bottom line: if you're building a full-stack TypeScript app and the API is for your own frontend, try tRPC. The type safety pays off quickly.