REST vs GraphQL vs tRPC in 2026: Choosing an API Style
REST, GraphQL, and tRPC solve different problems. A senior dev framework for picking an API style based on your team, clients, and constraints.
On this page
The wrong question is "which API style is best." The right one is "who is calling my API, and what do they need from it." I've shipped all three in production over the last decade, and the failures I've watched were never about the technology being bad. They were about picking GraphQL for a single TypeScript app that had exactly one consumer, or REST for a mobile team that needed forty different response shapes and got versioning hell instead.
So let me give you the actual decision framework, with the same endpoint built three ways so you can see what each one costs you.
The endpoint, three ways
Here's the scenario: fetch a user and their recent orders. Trivial on purpose, because the differences show up in the plumbing, not the feature.
REST
REST leans on HTTP. URLs are resources, verbs are actions, and the response is whatever the server decides to send.
// GET /api/users/:id
import { Hono } from "hono";
const app = new Hono();
app.get("/api/users/:id", async (c) => {
const id = c.req.param("id");
const user = await db.user.findUnique({
where: { id },
include: { orders: { take: 5, orderBy: { createdAt: "desc" } } },
});
if (!user) return c.json({ error: "not_found" }, 404);
c.header("Cache-Control", "private, max-age=30");
return c.json(user);
});The win here is that the whole HTTP ecosystem just works. A CDN can cache that response on the Cache-Control header. A browser respects ETag. curl debugs it. Any language on earth can call it. The cost is the response shape is fixed: a mobile client that only wants the user's name still gets all five orders, and a dashboard that wants the last twenty orders has to hit a second endpoint or you add a ?limit= param and slowly reinvent a query language.
Pair REST with OpenAPI so the contract is machine-readable. This is non-negotiable in 2026 — it's how you get generated clients, request validation, and docs for free.
# openapi.yaml (excerpt)
paths:
/api/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
"200":
description: A user with recent orders
content:
application/json:
schema: { $ref: "#/components/schemas/UserWithOrders" }
"404":
description: Not foundGraphQL
GraphQL flips control. The client writes the query; the server exposes a graph and resolves whatever's asked for.
const typeDefs = /* GraphQL */ `
type Order { id: ID!, total: Int!, createdAt: String! }
type User { id: ID!, name: String!, orders(limit: Int = 5): [Order!]! }
type Query { user(id: ID!): User }
`;
const resolvers = {
Query: {
user: (_p, { id }) => db.user.findUnique({ where: { id } }),
},
User: {
// resolved per-user — this is where the N+1 hides
orders: (user, { limit }) =>
db.order.findMany({
where: { userId: user.id },
take: limit,
orderBy: { createdAt: "desc" },
}),
},
};Now the mobile client asks for { user(id:"1"){ name } } and gets exactly that — no orders, no over-fetch. The dashboard asks for orders(limit: 20) against the same endpoint. One graph, every client carves out its own slice. That's the entire reason GraphQL exists, and for products with many heterogeneous clients it's genuinely the right tool.
tRPC
tRPC throws out the schema layer entirely. There's no IDL, no codegen, no OpenAPI. The server's TypeScript types are the contract, and the client imports them directly.
// server/router.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string(), orderLimit: z.number().default(5) }))
.query(({ input }) =>
db.user.findUnique({
where: { id: input.id },
include: { orders: { take: input.orderLimit } },
})
),
});
export type AppRouter = typeof appRouter;// client/profile.tsx — fully typed, zero codegen
import { trpc } from "./trpc";
export function Profile({ id }: { id: string }) {
const { data, isLoading } = trpc.getUser.useQuery({ id, orderLimit: 10 });
if (isLoading) return <Spinner />;
// data.orders[0].total is typed end-to-end. Rename it on the
// server and this line is a compile error before you commit.
return <h1>{data?.name}</h1>;
}That last comment is the whole pitch. Change name to displayName in the Prisma schema, and your editor lights up red across the entire frontend instantly. No regenerate step, no drift between client and server. For a TypeScript monorepo where the same team owns both ends, this is the fastest feedback loop you can buy.
The N+1 trap nobody warns you about
GraphQL's flexibility has a sharp edge. That User.orders resolver runs once per user. Fetch a list of 50 users with their orders, and you've fired 1 query for the users plus 50 for the orders. That's the N+1 problem, and it's the single most common reason GraphQL APIs fall over under load.
The fix is DataLoader, which batches calls within a single tick of the event loop:
import DataLoader from "dataloader";
const orderLoader = new DataLoader(async (userIds: readonly string[]) => {
const orders = await db.order.findMany({
where: { userId: { in: [...userIds] } },
});
// must return results in the same order as the input keys
return userIds.map((id) => orders.filter((o) => o.userId === id));
});
const resolvers = {
User: { orders: (user) => orderLoader.load(user.id) },
};Now those 50 lookups collapse into 1 batched query. The point isn't that GraphQL is slow — it's that GraphQL hands you a footgun and expects you to know about DataLoader, query depth limiting, and cost analysis on day one. REST doesn't have this problem because you wrote the single query. With GraphQL the query is assembled at runtime by a client you may not control, which is also why per-field authorization and caching are genuinely harder.
The honest comparison
| Factor | REST + OpenAPI | GraphQL | tRPC |
|---|---|---|---|
| Best for | Public/partner APIs | Many diverse clients | TS monorepos, full-stack apps |
| Contract | OpenAPI spec | SDL schema | TS types (no artifact) |
| Codegen needed | Yes (clients) | Yes (clients/types) | No |
| Language support | Any | Any | TypeScript only |
| Over/under-fetching | Common | Solved | Per-procedure, your call |
| HTTP caching | Native, easy | Hard (POST, one endpoint) | Hard |
| N+1 risk | Low (you control SQL) | High without DataLoader | Low |
| Per-field auth | N/A (per-route) | Complex | Per-procedure (simple) |
| Versioning | Painful (URL/header) | Schema evolution | Refactor + types |
| Client/server coupling | Loose | Loose | Tight |
| Learning curve | Low | High | Low (if you know TS) |
| 2026 maturity | Dominant | Stable, federated | Production-ready |
What changed by 2026
A few things shifted that affect the decision:
- tRPC is no longer a bet. v11 is stable, the OpenAPI bridge means you can expose a typed REST surface for non-TS consumers, and it ships first-class adapters for Next.js, TanStack Start, and the standard fetch/Web API runtimes. It's a default-tier choice for internal TS work now, not a curiosity.
- GraphQL consolidated around federation. If you're doing GraphQL at scale, you're almost certainly running a federated gateway (Apollo Federation or a Mesh-style composition) stitching subgraphs from independent teams. The single-monolith-schema era is over.
- REST + OpenAPI never left. It's still the overwhelming majority of public APIs, and the tooling around generating typed SDKs from an OpenAPI doc (
openapi-typescript,orval, server stubs) got good enough that "REST is untyped" is no longer a fair complaint.
The takeaway: all three are mature. This is a fit decision, not a maturity decision.
The decision framework
Stop agonizing. Walk these in order and stop at the first match.
-
Is the API public, or consumed by clients you don't own (partners, third parties, other languages)? → REST + OpenAPI. You need ubiquity, HTTP caching, and a language-agnostic contract. Don't make an external team learn your GraphQL schema or import your TS types.
-
Do you own both ends, and is it all TypeScript in one repo or a shared workspace? → tRPC. The end-to-end type safety with zero codegen is a multiplier on a small-to-medium team's velocity. Accept the coupling; it's a feature here, not a bug.
-
Do you have many genuinely different clients — web, iOS, Android, partners — each needing different slices of the same data, and is client-driven querying the actual problem you have? → GraphQL. Pay the N+1, caching, and auth tax knowingly. It's worth it when query flexibility is the core requirement, not a nice-to-have.
-
None of the above cleanly? → REST + OpenAPI. It's the lowest-regret default. The cost of "boring" REST is real but bounded; the cost of GraphQL you didn't need is a permanent operational tax, and the cost of tRPC you outgrow is a migration when a non-TS client shows up.
One more rule I hold to: don't run two styles to dodge a decision. A tRPC core for your own app plus a thin REST/OpenAPI layer for external consumers is a legitimate, common pattern — tRPC even generates it for you. But standing up GraphQL "for flexibility" on top of a REST API nobody asked to be flexible is how you end up maintaining three contracts for one set of data. Pick the one that fits the consumer in front of you, and earn the second style only when a real second consumer arrives.