Why I still reach for tRPC in 2026

4 min

read

#dx

The boundary problem

"The hardest part of building a full-stack TypeScript application isn't the frontend and it isn't the backend. It's the layer between them. Without something enforcing the contract at that boundary, you end up in a particular kind of debugging hell: the backend returns a field the frontend isn't expecting, or stops returning one it was, and you find out at runtime. In development this is annoying. In production at 2am it's something else. tRPC makes the server's procedure types available directly in the client. If the backend changes a response shape, the frontend type-checks against the new shape. The compiler finds the mismatch before the user does."

What the objections actually are

"The most common objection is coupling: your frontend and backend are too tightly linked. This is technically true. It's also, for most of the products I've worked on, not a problem. Caspian had two engineers. We were already tightly coupled — we sat near each other. The decoupling benefits of a REST API mostly matter when your frontend team and backend team are different teams.

The second objection is that it only works with TypeScript. Also true. If you're not using TypeScript end-to-end, tRPC is not for you. The third is that it doesn't work for public APIs. Completely valid — tRPC is for internal APIs, the ones your own frontend calls. The moment external developers need to consume your API, you need a documented REST or GraphQL interface."

A short example from Caspian

// server
export const chartRouter = router({
  getMetrics: publicProcedure
    .input(z.object({
      storeId: z.string(),
      range: z.enum(['7d', '30d', '90d'])
    }))
    .query(({ input }) => getMetrics(input.storeId, input.range)),
});

// client — fully typed, no manual type imports
const { data } = trpc.chart.getMetrics.useQuery({
  storeId, range: '30d'
});
// server
export const chartRouter = router({
  getMetrics: publicProcedure
    .input(z.object({
      storeId: z.string(),
      range: z.enum(['7d', '30d', '90d'])
    }))
    .query(({ input }) => getMetrics(input.storeId, input.range)),
});

// client — fully typed, no manual type imports
const { data } = trpc.chart.getMetrics.useQuery({
  storeId, range: '30d'
});
// server
export const chartRouter = router({
  getMetrics: publicProcedure
    .input(z.object({
      storeId: z.string(),
      range: z.enum(['7d', '30d', '90d'])
    }))
    .query(({ input }) => getMetrics(input.storeId, input.range)),
});

// client — fully typed, no manual type imports
const { data } = trpc.chart.getMetrics.useQuery({
  storeId, range: '30d'
});

"The range field is an enum on both sides. Add a new range value on the backend and the frontend knows about it immediately. Remove one and the compiler tells you everywhere it's being used."

Where I wouldn't use it

"A monolith where frontend and backend share a codebase directly. A product with a public API third parties consume. A team where frontend and backend are on separate repos with separate deployment cycles and engineers who don't talk enough. In those cases the coupling tRPC introduces is a real cost, not a hypothetical one."

The honest version

"tRPC is not a revolutionary technology. It's a thin layer that enforces a contract you should have been enforcing anyway. What it removes is the manual work of keeping that contract up to date — the duplicated types, the OpenAPI specs that drift from the implementation, the runtime errors a type system should have caught."

Create a free website with Framer, the website builder loved by startups, designers and agencies.