■Backend

GraphQL vs REST vs tRPC: Picking an API Layer Without Regret

A decision framework instead of a hype cycle. Caching, type safety, client diversity, team size and the WordPress/Shopify realities that push most projects to REST.

Published 8 min readBy Shawab Khan

TL;DRKey takeaways

  1. REST is the default for public APIs and anything a third party, a CMS or a CDN needs to cache. It is boring, and boring is a feature.
  2. GraphQL earns its complexity when many different clients need different shapes of the same graph. One client with one shape rarely needs it.
  3. tRPC is the right answer when the frontend and backend are one TypeScript codebase: end-to-end types with zero schema files.
  4. The real costs are caching (REST wins), N+1 queries (GraphQL needs DataLoader), and lock-in (tRPC is TypeScript-only).
  5. You can mix: tRPC internally, REST for partners, GraphQL for a content graph. The mistake is picking one for ideological reasons.

In one week last quarter I worked on three APIs: a WordPress WPGraphQL backend feeding a Next.js frontend, a REST API consumed by a Shopify app and a mobile team, and a tRPC layer inside a Next.js SaaS. Each was the right choice for its project and each would have been wrong for the other two. Here is the framework I use to decide, without the tribal arguments.

The same endpoint three ways

Fetching an order with its customer and line items:

// REST: predictable URLs, HTTP semantics, cacheable by anything
GET /api/orders/7f3e-…?include=customer,items
→ { id, status, customer: {…}, items: [{…}] }

// GraphQL: the client declares the shape it wants
query { order(id: "7f3e-…") { id status customer { name email } items { sku qty priceCents } } }
→ { data: { order: { … exactly those fields … } } }

// tRPC: a typed function call; the "API" is a TypeScript type
const order = await trpc.orders.get.query({ id: '7f3e-…' });
//    ^? Order  -- inferred from the server, no codegen, no schema file

REST: the default, and why

REST is not a technology; it is using HTTP the way HTTP was designed. Resources have URLs, verbs have meaning, responses have status codes and cache headers. That last part is the killer feature: a REST GET can be cached by the browser, a CDN, Cloudflare, Nginx and Varnish with zero extra work, because they all understand Cache-Control and ETag. The production REST API guide shows what a proper one looks like.

Choose REST when:

  • Third parties or unknown clients will consume it (partners, mobile teams you do not control, Zapier).
  • Responses are cacheable at the edge. Product catalogues, content, public data.
  • The team spans languages: PHP backend, Swift app, React frontend. Everyone speaks HTTP.
  • You are building on WordPress, Shopify or any platform that already exposes REST. Do not add a translation layer for its own sake.

Where it hurts: over-fetching and under-fetching. The mobile team needs three fields; the dashboard needs thirty; you end up with ?fields= and ?include= parameters that are GraphQL with worse ergonomics. Versioning is also on you.

GraphQL: for graphs with many consumers

GraphQL shines when the data is a graph (posts → authors → other posts → categories) and different clients need different slices of it. One schema, every client asks for exactly what it needs, and the type system doubles as documentation. It is why WPGraphQL is my pick for headless WordPress: post + ACF fields + author + terms + SEO in one round trip.

Choose GraphQL when:

  • Many client types with different data needs share one backend.
  • The data is deeply relational and clients navigate it in unpredictable ways.
  • You want a self-documenting, introspectable contract and codegen for every client.
  • You are federating several backends into one graph (large organisations).

Where it hurts:

  • Caching. Everything is a POST to /graphql, so HTTP caches see nothing. You need persisted queries, a GraphQL-aware cache, or client caches like Apollo's normalised store, which is its own project.
  • N+1 queries. A naive resolver for posts { author { name } } runs one author query per post. DataLoader batching is mandatory, not optional.
  • Unbounded queries. A client can ask for posts { comments { author { posts { comments … } } } }. You need depth and complexity limits from day one.
  • Error semantics. HTTP 200 with an errors array confuses every monitoring tool that keys on status codes.
// The N+1 fix you must ship with any GraphQL API
import DataLoader from 'dataloader';

const authorLoader = new DataLoader<string, Author>(async (ids) => {
  const rows = await sql`SELECT * FROM authors WHERE id = ANY(${ids as string[]})`;
  const byId = new Map(rows.map(r => [r.id, r]));
  return ids.map(id => byId.get(id) ?? new Error(`author ${id} missing`));   // preserve order!
});

const resolvers = {
  Post: { author: (post) => authorLoader.load(post.authorId) },   // 100 posts → 1 query
};

tRPC: when it is all one TypeScript codebase

tRPC drops the schema entirely. You write server functions with Zod input validation; the client imports the server's type (not its code) and gets a fully typed client with autocompletion. Rename a field on the server and every client usage is a compile error before you even save. For a Next.js SaaS where the same team owns both sides, it removed an entire category of bugs on a recent project.

// server/routers/orders.ts
import { z } from 'zod';
import { router, protectedProcedure } from '../trpc';

export const ordersRouter = router({
  get: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(({ input, ctx }) => ctx.repo.orders.findForUser(input.id, ctx.user.id)),

  create: protectedProcedure
    .input(z.object({ items: z.array(z.object({ sku: z.string(), qty: z.number().int().min(1) })).min(1) }))
    .mutation(({ input, ctx }) => ctx.services.orders.create(input, ctx.user.id)),
});

// client (React) -- nothing generated, types flow from the router type
const { data, isLoading } = trpc.orders.get.useQuery({ id });
//      ^? Order | undefined
const create = trpc.orders.create.useMutation({ onSuccess: () => utils.orders.list.invalidate() });

Choose tRPC when:

  • Frontend and backend are TypeScript, in one repo, owned by one team.
  • There are no external consumers (or you expose a separate REST surface for them).
  • You want the validate-at-the-boundary pattern without maintaining schemas twice.

Where it hurts: it is TypeScript or nothing; a Swift app or a PHP partner cannot use it. Its HTTP shape is RPC, so edge caching is limited to what you do manually. And it couples deploys: server and client types must match, which is trivial in a monorepo and painful otherwise.

The decision matrix

CriterionRESTGraphQLtRPC
External / multi-language clientsExcellentGood (codegen per language)No
Edge / HTTP cachingExcellentPoor without extra toolingLimited
End-to-end type safetyManual (OpenAPI codegen)Good (codegen)Excellent (zero config)
Flexible data shapes per clientPoorExcellentModerate (write more procedures)
Learning curve / tooling weightLowHighLow (if TS)
Performance footgunsOver-fetchingN+1, deep queriesFew
Fits WordPress / Shopify ecosystemsNativeWPGraphQL, Shopify Storefront APINo
Best forPublic APIs, content, integrationsContent graphs, many clientsSingle-team TS apps

Mixing them is normal

The SaaS I mentioned uses tRPC for its own dashboard, exposes a small versioned REST API for customers' integrations, and consumes Shopify's GraphQL Storefront API for product data. Three styles, no conflict, because each faces a different audience. The anti-pattern is forcing every consumer through the style the core team happens to like.

If you are still unsure

Start with REST. It is the easiest to cache, the easiest to hand to another team, and the easiest to put a GraphQL or tRPC layer in front of later if a real need appears. Nobody has ever regretted a clean REST API; plenty of teams have regretted a GraphQL server they did not need. The same instinct applies to service boundaries.

Whichever you pick, the boundary work is identical: validate input, return consistent errors, paginate with cursors, rate limit, and log with request IDs. The style is the interface; the discipline underneath is what makes it production-grade.

Shawab Khan

Written by

Shawab Khan

Shawab is a full-stack web developer based in Aligarh, India, with 7+ years and 100+ shipped projects across WordPress, Shopify, React and PHP. He currently builds high-performance sites for 30+ clients at UMM Digital and writes about the architecture decisions behind them.

Keep reading

→ Contact Freelance & full-time

Need this built for your project?

I design and ship production systems like the one above for agencies and startups: WordPress, Shopify, React and Node.js, with performance and SEO built in from day one.