The Art of API Design: REST, GraphQL, tRPC, and When to Use Each
APIs are the contracts that hold modern software together. Every frontend talks to a backend. Every microservice talks to another. Every mobile app, CLI tool, and webhook depends on a well-designed API.
But "well-designed" means very different things depending on which paradigm you choose. REST, GraphQL, and tRPC each solve the same fundamental problem — client-server communication — with radically different philosophies.
This isn't a "which is best" article. It's a "which is best for your situation" guide.
REST: The Standard That Refuses to Die
The Philosophy
REST (Representational State Transfer) maps your data to URLs and uses HTTP verbs to operate on them. It's resource-oriented: you have nouns (resources) and verbs (GET, POST, PUT, DELETE).
Why It Works
- Universal — Every language, framework, and tool speaks HTTP. REST APIs work with curl, Postman, browsers, and any HTTP client.
- Cacheable — HTTP caching (CDN, browser, proxy) works out of the box. A GET request to the same URL returns cached responses automatically.
- Stateless — Each request contains everything the server needs. No session state. This makes REST APIs horizontally scalable.
- Well-understood — Every developer knows REST. Onboarding is instant.
Where It Falls Apart
Over-fetching and under-fetching. This is REST's Achilles heel.
Versioning hell. When your API evolves, you either break clients or maintain multiple versions: /api/v1/users, /api/v2/users, /api/v3/users...
No type safety. The response is whatever JSON the server sends. Your TypeScript types are a lie — they're just your hope of what the response looks like.
When to Use REST
- Public APIs consumed by external developers
- Simple CRUD applications
- APIs that need aggressive HTTP caching
- When your consumers use many different languages/platforms
- Webhooks and integrations
GraphQL: Query Exactly What You Need
The Philosophy
GraphQL flips the model. Instead of the server deciding what data to return, the client specifies exactly what it needs in a query language.
One request. Exact data. No over-fetching. No under-fetching.
Why It Works
- Precise data fetching — The client controls the shape of the response. Mobile clients can request less data than desktop clients from the same API.
- Single endpoint — Everything goes through
POST /graphql. No more guessing URL structures. - Self-documenting — The schema is the documentation. Tools like GraphiQL let developers explore the API interactively.
- Strong typing — The schema defines every type, field, and relationship. Codegen tools generate TypeScript types from the schema automatically.
- Ecosystem — Apollo, Relay, urql, and others provide caching, optimistic updates, and real-time subscriptions out of the box.
Where It Falls Apart
Complexity tax. GraphQL introduces a schema definition language, resolvers, dataloaders (to avoid N+1 queries), and a whole new mental model.
Caching is hard. REST caches at the HTTP level (URLs). GraphQL sends POST requests to a single endpoint with varying query bodies — CDN caching breaks completely. You need application-level caching (Apollo Cache, Relay Store).
Security surface. Clients can craft expensive queries that tank your server:
You need query complexity analysis, depth limiting, and rate limiting to prevent abuse.
File uploads require multipart form-data workarounds. GraphQL wasn't designed for binary data.
When to Use GraphQL
- Apps with complex, nested data relationships
- Multiple clients (web, mobile, TV) needing different data shapes from the same API
- Rapidly evolving frontends where over-fetching is a real performance problem
- When you have a dedicated frontend team that benefits from schema-first development
tRPC: Delete the API Layer
The Philosophy
tRPC asks a radical question: if your frontend and backend are both TypeScript, why maintain an API contract at all?
Instead of defining schemas, writing routes, generating types, and keeping them in sync — tRPC lets you call backend functions directly from the frontend with full end-to-end type safety. No code generation. No schemas. No runtime overhead.
Why It Works
- Zero API contract maintenance — Change a function on the server, get instant TypeScript errors on the client. No codegen step. No schema syncing.
- Full-stack type safety — Input validation (Zod), return types, and error types flow from server to client automatically.
- No overhead — tRPC adds ~2KB to your bundle. Under the hood, it's just HTTP (or WebSockets). No special runtime.
- Incredible DX — Autocomplete works across the entire stack. Rename a field on the server, and your IDE updates every client reference.
- Composable — Middleware, context, and subscriptions work naturally. Authentication, logging, and error handling compose like regular functions.
Where It Falls Apart
TypeScript only. If your client isn't TypeScript (Swift, Kotlin, Dart, Python), tRPC gives you nothing. It's a TypeScript-to-TypeScript bridge.
Monorepo bias. tRPC works best when client and server share a type import. In a monorepo, this is trivial. Across separate repos, you need to publish the type package — which adds friction.
Not for public APIs. tRPC generates an RPC-style API (not REST, not GraphQL). External developers can't consume it with Postman or curl in any intuitive way. It's an internal protocol.
Scaling boundaries. When your "backend" splits into multiple services owned by different teams (possibly in different languages), tRPC's single-router model breaks down.
When to Use tRPC
- Full-stack TypeScript applications (Next.js, SvelteKit, Remix)
- Internal APIs where the same team owns frontend and backend
- Rapid prototyping where type safety matters but API contract overhead doesn't
- Monorepos where client and server share types naturally
The Comparison
The Decision Framework
1. Who consumes the API?
- External developers / public → REST. It's the lingua franca.
- Your own frontend team → GraphQL or tRPC.
- Same team, same repo, all TypeScript → tRPC. No contest.
2. How complex is the data?
- Simple CRUD → REST. Don't overcomplicate it.
- Deeply nested, relational → GraphQL shines here.
- Moderate complexity, full-stack TS → tRPC.
3. How many clients?
- One client (your web app) → tRPC or REST.
- Multiple clients with different needs → GraphQL.
- Third-party integrations → REST with OpenAPI docs.
4. What about the hybrid approach?
Modern apps often use multiple paradigms:
- REST for webhooks and third-party integrations
- tRPC for the internal web app API
- GraphQL for the mobile app that needs flexible querying
This isn't indecisive — it's pragmatic.
Real-World Patterns
The Full-Stack TypeScript Stack (My Preference)
REST for External + tRPC for Internal
Wrapping Up
There's no "best" API paradigm. There's only the right one for your context:
- REST when you need universality, caching, and public consumption
- GraphQL when you need flexible, precise data fetching across multiple clients
- tRPC when you're building full-stack TypeScript and want to move fast with zero API overhead
The best teams don't pick one religion — they pick the right tool for each boundary in their system.