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.