You're about to pick an API style. Someone on the team read a thread about GraphQL's flexibility, someone else wants tRPC because it's "just functions," and the loudest opinion is winning. Whatever you choose, your clients, your integrations, and your public surface area will be shaped by it for the next three to five years.
This is not a reversible decision. Migrating a production API style means rewriting every client and breaking every integration partner who built against you. The cost of getting it wrong is not a refactor — it's a deprecation cycle measured in quarters, with paying customers stuck on the old surface the entire time.
So let's make the decision on the actual mechanics, not the thread.
The question nobody asks first: who is the client?
Every API-style argument that goes nowhere skips this. The right answer depends almost entirely on who calls the API and whether you control them.
- You control both ends, one codebase, one team. Internal product, web + mobile clients you ship.
- You control both ends, but the front end is a separate team or a separate release cadence. Common at Series A.
- Third parties integrate against you. Partners, customers building on your platform, a public API as a product surface.
These three worlds have different correct answers. A team that picks tRPC because it's frictionless internally and then tries to expose it to integration partners has built a wall they'll spend a year tearing down.
When REST wins
REST is the boring answer and it is correct more often than the thread admits.
REST wins when you have third-party consumers, when you need cacheability, and when you want the largest possible pool of engineers who already know how your API works without reading docs. A GET /v1/invoices/{id} is self-describing. Every HTTP cache, proxy, CDN, and gateway in existence understands it. Every client library in every language speaks it natively.
REST also wins on operational legibility. When something breaks at 3 a.m., a REST endpoint shows up in your logs as a method, a path, and a status code. You can see what was called and what it returned without decoding a query body.
The REST failure mode
REST's failure mode is the over-fetch / under-fetch tax and endpoint sprawl. A mobile client needs four fields; the endpoint returns forty. A dashboard needs data from three resources; that's three round trips, or you build GET /dashboard-summary — a bespoke endpoint that exists to serve one screen. Do this enough times and your "RESTful" API is forty special-case endpoints shaped like your current UI. When the UI changes, the API rots.
The defense is versioning discipline and resisting the urge to shape endpoints around screens. Most teams don't, and that's how REST APIs become unmaintainable — not from the style, but from undisciplined growth.
When GraphQL earns its complexity
GraphQL is not free. It is a second system — a schema, a resolver layer, a query planner, a caching strategy that you now own instead of getting from HTTP. It earns that cost in specific conditions:
- Many clients with divergent data needs. Web, iOS, Android, a partner portal, an internal admin — each wants a different shape of the same graph. GraphQL lets each ask for exactly what it needs without you shipping a new endpoint per shape.
- Deeply relational data where round trips hurt. One query walks
account → projects → members → permissionsin a single request instead of an N+1 cascade of REST calls. - A front-end org that ships faster than the back-end org. GraphQL decouples them: front end composes new queries against the existing schema without waiting on a back-end deploy.
The GraphQL failure modes (there are several)
N+1 resolvers. The default naive resolver fires one database query per node in the graph. A query returning 100 projects with their members can fan out into thousands of queries. The fix — DataLoader-style batching — is mandatory, not optional, and teams discover this under production load.
Caching evaporates. You traded HTTP's free caching for a single POST endpoint that's opaque to every CDN and proxy. You now build caching yourself, per-field, with invalidation logic you maintain.
Unbounded query cost. A malicious or careless client can request a query that's cheap to write and ruinous to execute. You need query depth limits, complexity analysis, and persisted queries — security and cost controls that REST gave you for free via rate limiting on discrete endpoints.
GraphQL is right when you have the team to own all of that. It is a liability when you adopt it for flexibility you don't yet need.
When tRPC is right — and the lock-in nobody mentions
tRPC is genuinely excellent in one configuration: a TypeScript monorepo where you own the client and the server, and no third party ever calls your API. You get end-to-end type safety with zero schema duplication, zero codegen step, and a refactor that breaks the client at compile time the instant you change the server. For an internal product team shipping a Next.js front end against a TypeScript back end, the velocity is real.
That's the whole pitch, and it's a good one. Now the cost.
The tRPC lock-in
tRPC's type safety comes from importing server types into the client. That coupling is the feature — and the trap. The moment you need a consumer that isn't your TypeScript client, tRPC gives you nothing. There's no schema artifact a partner can codegen against. No OpenAPI spec. No language-agnostic contract. A Python data team, a mobile app in Swift, an integration partner in Go — none of them can consume tRPC the way it's meant to be consumed.
So the real question for tRPC is not "is it nice to use" — it is — but "am I certain I will never expose this API to a consumer I don't compile alongside it?" If you can't promise that with a straight face, you're choosing a tool that will require you to bolt a REST or GraphQL layer on top later. Now you maintain two API styles.
Versioning: the decision underneath the decision
Whatever style you pick, you will change your API. Plan for it on day one or pay for it later.
- REST: version in the path (
/v1/,/v2/) for major breaks; add fields additively within a version (never remove or retype a field in place). Publish a deprecation policy with dates, not vibes. Run versions in parallel during the migration window. - GraphQL: the schema is the version. Evolve by adding fields and
@deprecateddirectives; never break a field's type or remove it without a deprecation cycle. The discipline is harder because there's no/v2/escape hatch — you live in one evolving schema forever. - tRPC: you don't really version — you ship client and server together. Which is exactly why it's wrong for external consumers, who can't redeploy in lockstep with you.
The failure mode that's universal: shipping breaking changes without a deprecation window because "it's only one client." It's never only one client for long.
What fixed looks like
The decision, made on mechanics instead of threads:
- Public API or third-party integrators? REST, versioned in the path, additive within versions. Boring, cacheable, universally consumable. Add GraphQL later only if integrators demand graph traversal you can't serve cleanly.
- Many first-party clients with divergent data needs and a back-end team that can own resolvers, batching, and query-cost controls? GraphQL.
- One TypeScript product team, full-stack control, no external consumers, ever? tRPC — with eyes open about the coupling.
And the meta-rule: don't pick the style that's most fun to write. Pick the one whose failure mode you can live with. REST sprawls. GraphQL fans out and stops being cacheable. tRPC locks you to one language. Choose your problem deliberately.
./decide --by=consumer --not=hype
This is for you if
You're at the point where the API surface you ship becomes a contract — funded, with paying customers or integration partners on the horizon, and an engineering decision that's expensive to unwind. This is the $25k+ conversation: architecting the API contract, versioning strategy, and the boundary between internal and external surface before you've committed clients to it.
It's the right time if you're choosing between styles now, or if you've already shipped tRPC internally and are staring down a partner who needs to integrate. That's an architecture decision with a deadline — exactly what we do.
This is not for you if you're pre-product and shipping to a single internal user to validate demand. Pick whatever your team ships fastest in, keep the surface small, and revisit when a real second consumer appears. Buying API governance before you have two clients is paying for a problem you don't have yet.
< transmit >