All posts
Web Development··8 min read

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.

By

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 found

GraphQL

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

FactorREST + OpenAPIGraphQLtRPC
Best forPublic/partner APIsMany diverse clientsTS monorepos, full-stack apps
ContractOpenAPI specSDL schemaTS types (no artifact)
Codegen neededYes (clients)Yes (clients/types)No
Language supportAnyAnyTypeScript only
Over/under-fetchingCommonSolvedPer-procedure, your call
HTTP cachingNative, easyHard (POST, one endpoint)Hard
N+1 riskLow (you control SQL)High without DataLoaderLow
Per-field authN/A (per-route)ComplexPer-procedure (simple)
VersioningPainful (URL/header)Schema evolutionRefactor + types
Client/server couplingLooseLooseTight
Learning curveLowHighLow (if you know TS)
2026 maturityDominantStable, federatedProduction-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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.