REST vs GraphQL vs tRPC in 2026: Which API Layer Should You Build On
Three Ways to Draw the Line Between Client and Server
Every full-stack app has a seam: the point where the frontend stops and the backend begins. How you design that seam — the shape of the requests, who is allowed to call it, how much the two sides know about each other's types — is one of the highest-leverage architecture decisions you'll make. It's hard to change later, and it colours everything from developer speed to how buyers judge your code.
In 2026, three approaches dominate that seam in the TypeScript and Next.js world, and they make fundamentally different bets:
Pick the wrong one and the symptoms are predictable: you expose a tRPC "API" that a mobile team can't consume, you stand up a full GraphQL server for an app with one client that only ever needs fixed data, or you hand-roll dozens of REST endpoints and spend your life keeping frontend types in sync with backend responses by hand.
This guide compares all three through two lenses: which is best to build on, and which produces a codebase that's clean to hand off or sell.
Network cables and server infrastructure representing API connections
First, The Distinction That Makes This Whole Category Make Sense
Before comparing features, the single most important point: tRPC is not the same kind of thing as REST and GraphQL.
Get this right and the whole decision clarifies. The question isn't "which is fastest" or "which is trendiest" — it's who consumes this API? If the answer is "my own TypeScript frontend, and only that," tRPC is built for you. If the answer includes "other languages, mobile apps, third parties, or the public," you need a real protocol: REST or GraphQL. This is the same coherence-over-hype thinking behind picking a sensible tech stack for web apps in 2026.
At a Glance
| REST | GraphQL | tRPC | |
|---|---|---|---|
| First popularized | Early 2000s | 2015 (Facebook) | 2020 |
| What it is | Protocol (resources + HTTP) | Protocol (query language) | TypeScript RPC layer |
| Consumers | Any language, anyone | Any language, anyone | TypeScript clients you own |
| Type safety | Manual (or via OpenAPI) | Via schema + codegen | End-to-end, no codegen |
| Over-fetching | Common | Solved (pick fields) | Per-procedure shape |
| HTTP caching | Native (GET URLs) | Harder (single POST) | Via batching, not CDN |
| Server complexity | Low | High (schema, resolvers) | Low |
| Public API | Excellent | Excellent | No |
| Best for | Public / polyglot APIs | Large multi-client graphs | TypeScript full-stack you own |
Note: all three evolve, and each has a large ecosystem of frameworks and conventions. Treat this table as a map, not a spec sheet — verify current behaviour against the official docs before you commit.
The Design Decision That Explains Each One
Almost every practical difference follows from one bet each approach made.
REST: model everything as resources anyone can address
REST's bet is that a uniform, resource-oriented interface over plain HTTP is the most durable, universal way to expose data. You have nouns (resources) at URLs, and you act on them with standard verbs.
// REST — resources and verbs, consumable by anything
GET /api/users // list users
GET /api/users/1 // one user
POST /api/users // create
PATCH /api/users/1 // update
DELETE /api/users/1 // delete
GET /api/users/1/orders // that user's ordersWhat you get is universality and simplicity: every language, framework, and tool speaks HTTP, cacheable GET URLs get free CDN and browser caching, and there's almost nothing conceptual to learn. What you pay is over-fetching and under-fetching — an endpoint returns a fixed shape (often more than a screen needs), and assembling one view from several resources means several round trips or a bespoke endpoint. Type safety is also on you: unless you layer OpenAPI and code generation on top, nothing stops the frontend and backend types from drifting apart.
REST is the right answer when your API is public, polyglot, or cacheable-by-nature — the safe, universal default for anything the outside world consumes.
GraphQL: let the client ask for exactly what it needs
GraphQL's bet is that with many clients pulling different slices of a large, interconnected data graph, the server shouldn't dictate fixed response shapes. Instead, one endpoint exposes a typed schema, and each client sends a query for precisely the fields it wants.
# GraphQL — one request, exactly the fields this screen needs
query {
user(id: "1") {
name
orders(last: 3) {
total
items { title }
}
}
}That single request replaces what might be three REST round trips, and it returns no more than asked. For a big product with mobile and web clients, many teams, and a rich graph of related data, that precision is a real win — no over-fetching, no under-fetching, one typed schema as the contract.
The honest caveat is operational complexity. You need a schema and resolvers, a strategy for the N+1 query problem (typically a batching layer like DataLoader), and a caching plan — because everything is usually one POST to /graphql, you lose REST's free HTTP caching and lean on client-side normalized caches and persisted queries. GraphQL is powerful, but it's something you *operate*, not something you drop in.
Developer working with data and code on multiple screens
tRPC: delete the boundary when you own both sides
tRPC's bet is narrower and, under its condition, radical: if the client and server are both TypeScript and the same team owns them, the entire serialization-contract dance is waste. So it lets you define server procedures and call them from the client as if they were local typed functions — the types flow through a single import, with no schema and no code generation.
// tRPC — define procedures on the server...
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => db.user.findUnique({ where: { id: input.id } })),
addUser: publicProcedure
.input(z.object({ name: z.string() }))
.mutation(({ input }) => db.user.create({ data: input })),
});
// ...and call them on the client, fully typed, no codegen
const user = await trpc.getUser.query({ id: "1" });
const created = await trpc.addUser.mutate({ name: "Ada" });What you get is the tightest developer loop in this comparison: rename a field or change an input on the server and the client lights up with a type error *at build time*, before anything ships. Input validation (via Zod or similar) is built into each procedure, and there's essentially no boilerplate. What you pay is the hard boundary from the intro — it only works inside your TypeScript world. It's the natural fit for a Next.js app, a SaaS, or any starter where one team owns the whole stack, which is exactly why there's a healthy market for tRPC templates and starters.
Type Safety: The Difference People Feel Day to Day
This is where the three separate most sharply in daily experience.
If a tight, zero-ceremony type loop within one codebase is what you value most, tRPC wins outright. If you need a typed contract that non-TypeScript clients also respect, GraphQL's schema is the stronger model. The reason any of this matters is the same reason TypeScript beats JavaScript for anything you'll maintain: the compiler catching a shape mismatch before runtime is worth a great deal — and your data types usually originate at the database, which is why this pairs so closely with your ORM choice.
Over-Fetching, Under-Fetching, and Round Trips
The classic REST pain is fetching a user, then their orders, then each order's items — three requests, and each returns fixed shapes you may not fully use. GraphQL was designed to erase exactly that: one query, the precise nested shape, one round trip. tRPC sits in between — each procedure returns whatever you coded it to return, so you avoid over-fetching by *designing* procedures for your screens, but you don't get GraphQL's arbitrary client-driven field selection.
The practical read:
Don't adopt GraphQL *solely* to avoid a couple of extra REST calls — that's paying graph-sized complexity to solve an endpoint-sized problem. Adopt it when field-precision across many clients is a recurring, structural need.
The Next.js App Router Changes The Question
Here's the shift that catches people out: in the Next.js App Router, a lot of data access moves to the server, *before* any API layer is involved.
Server Components fetch data on the server — you can call your database or service directly, with full type safety, and render the result into the initial HTML. Mutations run through Server Actions with typed arguments. For a page that displays server data and mutates it with a form, you may not need a client API layer at all.
Where an explicit API layer still earns its keep is interactive client state and consumers beyond your own render tree:
The common 2026 pattern is both: Server Components and Server Actions for server-side fetching and mutations, plus tRPC for the client-side interactive calls that want a typed, batched, organized API — and a small REST surface for the genuinely public parts (Stripe webhooks, a partner API). They compose rather than compete. If your public surface is the whole point, a dedicated Hono API starter is a clean way to build the REST side.
Two developers reviewing architecture together at a desk
Caching: The Quiet Deciding Factor
Caching is where REST's age becomes an advantage. Cacheable GET URLs get free browser, proxy, and CDN caching — an enormous, low-effort performance and cost win for read-heavy public APIs. That alone keeps REST the right call for many public surfaces.
GraphQL trades that away: with everything as one POST to /graphql, HTTP caching no longer applies out of the box, so you lean on client-side normalized caches (Apollo, urql, Relay) and server-side strategies, plus persisted queries to make responses cacheable again. It's solvable, but it's work.
tRPC caches at a different layer — it batches multiple calls into one HTTP request and pairs naturally with a client cache like TanStack Query (which it integrates with directly), giving you deduplication and background refetching on the client. What it doesn't give you is CDN-level caching of a public URL, because it isn't a public URL API.
If cheap, edge-cacheable reads for the public are central, REST has a structural edge. If your caching lives on the client for an app you own, tRPC's TanStack Query integration is excellent. GraphQL needs the most deliberate caching design of the three.
Versioning, Evolution, and Breaking Changes
How each style handles change matters for anything long-lived or consumed by others:
/v1/, /v2/ — which is clear but means maintaining old versions for consumers you can't force to upgrade.@deprecated, and avoid hard version bumps. Great for a schema many external clients depend on.The pattern: for public APIs with consumers you don't control, GraphQL's additive evolution or REST's explicit versions protect them. For an internal TypeScript app, tRPC turns "breaking change management" into "fix the type errors before you ship."
Bundle Size and Performance: What's Real
For the client, tRPC and a plain fetch-based REST client are light; GraphQL clients like Apollo add more weight for their normalized cache and features (lighter clients like urql exist). But as with most such comparisons, on a data-heavy app already shipping hundreds of kilobytes, the client-library difference is rarely your bottleneck.
The performance wins that matter are architectural, not library-branded: round-trip count (GraphQL and well-shaped tRPC procedures cut trips REST might multiply), caching (REST's CDN edge vs client caches), and the N+1 problem (a real GraphQL and ORM concern you solve with batching). Your API latency, database queries, and render tree decide performance far more than whether the wire format is REST, GraphQL, or tRPC.
Lock-In and Handoff: What Someone Inherits
If you're building something you'll hand to a client, sell on a marketplace, or leave for a team, your API layer is a big part of what they inherit.
Clean modern code on a laptop screen
Portable regardless of choice: your business logic, your database, your components. The data *access* style wraps them; the logic underneath moves with you.
Where the inheritance gets opinionated:
The failure mode that actually hurts resale isn't the style — it's incoherence: some routes REST, some ad-hoc fetch calls, no input validation, no consistent auth or error handling, types that lie about the runtime shape. Pick one primary style, validate inputs at the boundary, handle auth and errors consistently, and make the types match reality. A coherent API story is part of what separates a template buyers trust from one they abandon — see what makes code production-ready for the rest of that checklist.
Which One for Which Project
Choose tRPC if:
Choose REST if:
Choose GraphQL if:
If you're still unsure:
For a TypeScript app you own end to end — the common case — start with the App Router's Server Components and Server Actions, add tRPC for interactive client calls, and expose a small REST surface only for the genuinely public parts. Reach for GraphQL when field-precision across many clients becomes a real, repeated problem — not before.
The Bottom Line
There's no universal winner — there's a right API layer for who consumes it and who inherits it.
Whichever you choose, three habits outlast the decision: match each consumer to the right tool — tRPC for internal TypeScript traffic, REST for public and polyglot, GraphQL for a large multi-client graph — validate inputs and handle auth and errors at the boundary consistently, and make your types match reality so the contract never lies. Those cost an afternoon and save a month.
Ready to turn what you build into income? List your template or app on CodeCudos, see how the API layer fits the wider stack in our best tech stack for web apps in 2026 guide, settle the data-fetching question with TanStack Query vs SWR vs RTK Query, or make sure the whole codebase reads as production-ready.
