How to choose between GraphQL and REST for headless CMS
Choosing GraphQL or REST for a headless CMS sets your build times, cache strategy, content-modeling flexibility, and compliance posture — it’s an architectural constraint, not a protocol preference. This guide is a decision framework across four axes: fetching and cache, content modeling, build orchestration, and governance, each with the root cause behind the common failure and the exact fix. The outcome feeds your broader Headless CMS Architecture & Platform Selection.
The four axes collapse into one decision tree — any axis can tip the choice:
flowchart TD
A["Choosing a delivery API"] --> B{"Edge HTTP caching<br/>+ ISR critical?"}
B -->|Yes| REST["REST"]
B -->|No| C{"Content modular /<br/>polymorphic?"}
C -->|No, linear types| REST
C -->|Yes| D{"Serverless build<br/>timeout limits?"}
D -->|Yes, strict| REST
D -->|No| E{"Can run persisted<br/>queries + field RBAC?"}
E -->|No| REST
E -->|Yes| GQL["GraphQL"]
Step 1: Fetching architecture and cache strategy
REST maps each route to a content type or collection and returns a fixed payload. That predictability aligns with RFC 7234: HTTP/1.1 Caching — straightforward Cache-Control, CDN edge caching, and ISR — but pushes payload stitching to the frontend, producing request waterfalls on deep component trees.
GraphQL inverts that: one /graphql endpoint, a declarative query, exactly the fields requested. No over-fetching, fewer round-trips. The cost is HTTP cacheability — identical URLs return different payloads per query body, so edge caches don’t apply by default.
Root cause: Unbounded GraphQL queries skip the CDN and overload the origin during spikes; REST endpoints with nested relationships trigger sequential fetches that block render.
Fix:
- REST: Set
Cache-Control: public, max-age=3600, stale-while-revalidate=86400at the CDN and useETagfor conditional requests. - GraphQL: Use Automatic Persisted Queries (APQ) or static registration — hash queries at build time, store them in the CDN, and route production traffic to
/graphql?hash=abc123.
Prevention: Enforce depth limits (maxDepth: 5) and complexity scoring at the gateway. For REST, flatten nested relationships into composite endpoints or batch with JSON:API includes.
Step 2: Content modeling and schema constraints
The model dictates API behavior. REST’s flat or nested JSON mirrors tables and document schemas — fine for linear types (posts, product catalogs), awkward for polymorphic or deeply nested modular content. GraphQL’s type system handles unions, interfaces, and recursion, so frontends query new modular blocks without backend changes.
Governance diverges too. REST versions endpoints (/v1/posts, /v2/posts), which fragments docs and adds client routing logic. GraphQL evolves in place: deprecate with @deprecated rather than remove, and apply field-level access controls. Introspection powers auto-generated docs and preview integrations but needs strict linting to prevent drift.
Root cause: Rigid REST payloads force conditional rendering in components; unversioned GraphQL schemas break clients when fields are renamed or removed without deprecation.
Fix:
- REST: Adopt HAL or JSON:API standards to standardize relationship linking. Use OpenAPI 3.0 specifications to generate TypeScript clients via
openapi-typescript. - GraphQL: Define
interfacetypes for shared block structures. Use@deprecated(reason: "Use newField instead")for 2 release cycles before removal. Rungraphql-eslintin CI to block breaking changes.
Prevention: Lock a content-modeling contract before CMS configuration. On the broader GraphQL vs REST API Tradeoffs, default to GraphQL for modular, component-driven architectures and REST for linear, cache-heavy publishing.
Step 3: Build orchestration and DX metrics
Build pipelines amplify the choice. REST routes fetch in parallel during static generation, with ISR revalidating per route. GraphQL needs query extraction at build time; dynamically composed or unpaginated queries blow up build times.
Root cause: Missing pagination cursors in GraphQL exhaust memory during static generation; unbounded REST arrays time out serverless functions.
Fix:
- REST: Implement cursor-based pagination (
?cursor=xyz&limit=50). UsePromise.all()for parallel route data fetching in Next.js/Remix loaders. - GraphQL: Use
@connectiondirectives for pagination. Integrate@graphql-codegen/clito generate typed hooks. Configure build scripts to rungraphql-inspectorfor breaking change detection.
Prevention: Set per-route timeout thresholds (e.g., 30s) in CI, trigger incremental builds from webhook payloads instead of full-site regeneration, and track TTFB and build duration per integration.
Step 4: Governance and compliance controls
Enterprise deployments need audit trails, field-level access control, and WAF compatibility. REST proxies cleanly through API gateways with IP allowlists and rate limiting. GraphQL’s single endpoint breaks URL-pattern WAF rules — malicious queries arrive as POST bodies.
Root cause: Exposed GraphQL introspection leaks schema structure; REST without field-level filtering returns internal IDs and draft states to public clients.
Fix:
- REST: Apply attribute-based access control (ABAC) at the API gateway. Strip internal fields via response transformers before CDN delivery.
- GraphQL: Disable introspection in production (
introspection: false). Implement query allowlists that only execute pre-approved operations. Use@authdirectives for field-level RBAC.
Prevention: Validate schema security against the GraphQL Specification and keep an API registry with versioned docs and deprecation alerts for editors.
Troubleshooting matrix
| Symptom | Root Cause | Exact Implementation | Prevention |
|---|---|---|---|
| CDN cache miss rate > 40% | GraphQL queries vary per request; REST endpoints lack Cache-Control |
Hash queries to static URLs; set stale-while-revalidate on REST routes |
Enforce APQ; audit Cache-Control headers in CI |
| Build times exceed 15m | N+1 GraphQL queries or unpaginated REST arrays | Implement @connection pagination; use Promise.allSettled for REST |
Set query complexity limits; enforce cursor pagination |
| Frontend hydration mismatch | REST returns nested objects GraphQL expects flat; schema drift | Normalize payloads with normalizr; lock schema versions via CI gates |
Run graphql-codegen on pre-commit; use OpenAPI contracts |
| WAF blocks legitimate CMS requests | GraphQL POST payloads trigger SQLi/regex rules | Whitelist /graphql path; use query allowlists; disable introspection |
Configure WAF to inspect JSON bodies; use persisted queries |
| Content editors break production UI | Unvalidated modular content types in GraphQL unions | Add schema validation middleware; enforce required fields in CMS UI | Implement content type linting; run preview environment sync |
Decision Checklist
- Cache Strategy: Do you require edge-level HTTP caching with ISR? → REST. Do you need exact field selection with client-side composition? → GraphQL.
- Content Structure: Is your architecture linear (articles, products)? → REST. Is it modular (page builders, polymorphic blocks)? → GraphQL.
- Build Pipeline: Are you deploying to serverless with strict timeout limits? → REST. Can you manage persisted queries and incremental builds? → GraphQL.
- Compliance: Do you need traditional WAF/proxy routing? → REST. Can you implement query allowlists and field-level RBAC? → GraphQL.
The choice follows your caching requirements, content topology, and governance maturity — not protocol preference. Enforce schema contracts early and automate validation to keep integration drift out.