The problem tRPC solves: type drift
In a normal REST JSON or GraphQL app, the server's types and the client's types are maintained separately. You change a response field on the backend, forget to update the frontend type, and TypeScript happily compiles a bug that only shows up at runtime. This client/server type drift is a large share of production bugs in full-stack apps. tRPC eliminates it: the client imports the server's types *directly*, so a change to a backend procedure instantly shows up as a type error everywhere the frontend uses it — no schema file, no code generation.
By 2026 tRPC (~2M weekly downloads) is a default for full-stack TypeScript apps. Here's how it works and, importantly, when it's the wrong choice.
How it works: procedures, not endpoints
Instead of URLs and verbs, you define procedures — plain functions grouped into a router — on the server:
// server: router.ts
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => db.user.find(input.id)), // returns a User
createUser: publicProcedure
.input(z.object({ name: z.string() }))
.mutation(({ input }) => db.user.create(input)),
});
export type AppRouter = typeof appRouter; // ← the only thing the client needsThe client imports just the type AppRouter and calls procedures as if they were local functions, fully typed:
// client — input and output types are inferred from the server
const user = await trpc.getUser.query({ id: "u_1" });
// ^? User — autocomplete on user.name, type errors if the server changesThere's no generated SDK and no schema to keep in sync. The types *are* the contract, and they're checked at compile time across the network boundary.
It still ships JSON over HTTP
A common misconception is that tRPC is a new protocol. It isn't — tRPC sends ordinary JSON over HTTP. The type safety is a *compile-time* illusion built from shared TypeScript types; at runtime a query is a normal HTTP request with a JSON body and a JSON response. That means:
- You can inspect tRPC calls in the network tab like any JSON API — format the payload and it's just JSON.
- Input validation is real and runtime — the
.input(z.object(...))uses Zod (or any Standard Schema validator) to actually validate the incoming JSON, so bad data is rejected server-side, not just mistyped client-side. - Because it's HTTP + JSON, it works with normal caching, batching, and middleware.
The hard limitation: TypeScript on both ends
tRPC's superpower is also its boundary: it only works when the client and server are both TypeScript and can share types. That rules it out for:
- Public APIs consumed by third parties who don't have your types.
- Polyglot stacks — a Python data service, a Go microservice, a mobile app in Swift/Kotlin.
- Anything where the consumer isn't your codebase.
For those, you need a language-agnostic contract, which is exactly what REST and GraphQL provide.
Choosing between the three
| tRPC | REST | GraphQL | |
|---|---|---|---|
| Contract | Shared TS types | OpenAPI / convention | SDL schema |
| Type safety | End-to-end, automatic | Manual / generated | Generated from schema |
| Consumers | TS client only | Anyone | Anyone |
| Codegen | None | Optional | Usually |
| Best for | Full-stack TS apps | Public/polyglot APIs | Complex client-driven data |
The decision is mostly about who calls your API:
- TypeScript frontend + TypeScript backend, same team? → tRPC for the velocity and zero drift.
- Public API, third-party or multi-language consumers? → REST (with OpenAPI) for universality.
- Many clients with varied, nested data needs? → GraphQL for client-driven queries.
A newer option, oRPC, keeps tRPC's ergonomics but adds built-in OpenAPI output and works across languages — worth a look if you want tRPC's feel without the TypeScript-only ceiling. But the core rule stands: tRPC for internal full-stack TS, REST or GraphQL when the contract must cross a language boundary. For that comparison see GraphQL vs REST with JSON.