rapidlaunchcode.app
Enterprise StarterAdvisoryEngineeringProductsWritingAbout
Book a call
rapidlaunchcode.app

Independent technology advisory and engineering. København, Denmark.

Njalsgade 21F, 2. sal, København

CVR 45 44 13 93

Nicklas@rapidlaunchcode.app

WhatsApp +45 31 33 25 99

Work

  • Enterprise Starter
  • Advisory
  • Engineering
  • Products
  • Contact

Resources

  • Writing
  • Guides
  • Free tools
  • About

Elsewhere

  • HourIQ
  • Translately
  • NomadWorld
  • Privacy

© 2026 Rapid Launch Code ApS. All rights reserved.

Built in København with Next.js, Contentful, and zero consultancy bullshit.

Back to guides
13 min · CMS & CONTENT

REST + GraphQL hybrids for multi-locale CMS-driven sites

REST-only on a multi-locale CMS site means circular references on every full page fetch — and a fragile reference-resolver pass to clean them up. GraphQL-only means a 30–80 KB client on every browser page, plus rebuilding the Sync API and CMA flows you didn't need to touch. The right answer is a hybrid split by concern, not by API.

On this page
  • The six pillars of a multi-locale CMS-driven site
  • REST-only: what works
  • REST-only: where it breaks (and why circular references are the headline)
  • GraphQL-only: what works
  • GraphQL-only: where it stops working
  • Why neither alone is the right answer
  • Decision matrix: pick the right API per job
  • Module layout
  • One cmsFetch wrapper, two transports
  • Granular cache tags so editors do not blow the whole cache
  • Block components own their fragments — the typed-prop pattern
  • Multi-locale fallback in one query
  • Live Preview survives the migration
  • Editorial writes via Server Actions (CMA)
  • Search index sync stays on the REST Sync API
  • Resilience defaults inside cmsFetch
  • Migration sequencing — phased, value-first
  • The hybrid in one diagram
Updated 2026-04
TL;DR
  • REST-only is one mature SDK plus deep include: N graphs — but a single page request returns a graph with cycles (parent → child → button → parent), forcing fragile reference-stripping post-processing.
  • GraphQL-only kills the cycles by construction with explicit selection sets and gives you typed documents — but it ships a 30–80 KB client onto every browser page and has no answer for sync APIs, schema migrations, asset uploads, or in-app writes.
  • Multi-locale CMS-driven sites have six distinct concerns (render graph, locale fallback, hot-path layout fan-out, write surface, preview/draft flow, search index sync). No single API wins all six.
  • The right answer is a hybrid split by concern, not by API: GraphQL CDA on the render hot path, REST CDA SDK for sync + dictionaries + redirects, REST CMA for migrations + writes + asset uploads, all routed through one cmsFetch wrapper with a shared cache-tag contract.
  • Migrate in phases — getPageByPath first (kills the 1000-page scan), then layout fan-out, then block fragments. About 10 days of senior work, value-first, with a clear deletion target for the legacy reference resolver at the end.

The six pillars of a multi-locale CMS-driven site

Every enterprise multi-locale CMS site I have shipped — Contentful, Sanity, Storyblok, Strapi — runs into the same six concerns. They all have to be answered. They are very different problems, and no single API surface is best at all six.

  • Deep render graph. Pages reference blocks; blocks reference cards, assets, CTAs; CTAs reference internal pages; internal pages reference parent pages. The full payload of one page is a graph, not a tree, and it crosses the same nodes more than once.
  • Locale fallback chain. Every read can target de-DE, fall back to en-US, and ultimately en. Doing this client-side means N round-trips on cold cache, where N is the depth of the chain.
  • Hot-path layout fan-out. Every request — every page — needs site settings, navigation, and dictionaries before it can render. If those reads are over-fetched or chained, every page on the site pays for it.
  • Write surface. Form submissions, comments, lead capture, in-app entry creation, asset uploads. All of them hit the Management API, which has no GraphQL surface anywhere I have seen.
  • Preview/draft flow. The editor needs click-to-edit, real-time updates, draft tokens, and a separate cache discipline. The read API has to swap transparently between preview and delivery without rewriting the call sites.
  • Search index sync. Algolia, Typesense, Elastic — they all need a delta-sync API to stay current without re-indexing the entire space on every webhook. That delta-sync API is purpose-built REST. There is no GraphQL equivalent.

No single API gives you a clean answer to all six. That is why every enterprise CMS implementation that actually ships ends up hybrid — usually by accident, after months of patching. The honest version is to design the hybrid up front.

REST-only: what works

REST is the obvious starting point. The vendor SDK is mature, the URL caching story is well-understood, and one deep include: N call returns a useful answer for the simple shape (one entry, one render). For small sites, REST-only ships and ages well.

  • One SDK. The contentful package gives you one auth model, one mental model, one error surface. New developers can ship a feature on day one.
  • Deep include: 10 in one call. When the page actually is a tree (a single entry that renders one component), a deep include returns the full graph in a single round-trip. Hand-authoring the equivalent GraphQL selection set would be tedious.
  • Mature CDN caching by URL. REST endpoints are cacheable by URL — every reverse proxy, every edge cache, every browser knows what to do.
  • REST CMA + REST Sync API are first-class. Schema migrations, sidebar configuration, asset uploads, and Algolia delta sync all live on the management API or the sync API — both REST. You do not have to ship a second client just to support these flows.

REST-only: where it breaks (and why circular references are the headline)

The headline con of a REST-only Contentful (or Sanity, or Storyblok) implementation is not bandwidth. It is not even type safety. It is circular references on full page payloads, and the maintenance tax of the post-processing pass that has to clean them up.

The shape of the cycle

Consider an enterprise content model. A Page has a parentPage (self-referential — pages live in a hierarchy). A Page's contentBlocks include a BlockHero. The BlockHero has a primaryCta field, typed as a ButtonLink. The ButtonLink has an internalLink field — a reference to a Page. That target Page can be the same page (a self-link) or another page that also has a parentPage and CTAs.

Now ask the REST CDA for the page with include: 10. The SDK happily walks every reference until depth 10, returning a graph the browser cannot serialize without infinite traversal:

Reference graph (cycle)
Page (slug: about)
  ├── parentPage → Page (slug: company)
  │     └── parentPage → Page (slug: home)
  │           └── parentPage → null
  └── contentBlocks
        ├── BlockHero
        │     └── primaryCta → ButtonLink
        │           └── internalLink → Page (slug: about)   ← cycle back to root
        │                 └── parentPage → Page (slug: company)
        │                       └── ... include depth continues
        └── BlockFeatureGrid
              └── cards[].link → ButtonLink
                    └── internalLink → Page (slug: pricing)
                          └── contentBlocks[0] = BlockHero
                                └── primaryCta → ButtonLink
                                      └── internalLink → Page (slug: about)  ← another cycle

This is not pathological content modelling. This is what every real site looks like once authors can place CTAs on pages and pages live in a hierarchy. The cycle exists in the model. The REST API exposes it without comment.

The conventional fix (and why it is a permanent tax)

Every REST-shaped Contentful project ends up with a reference-resolver.ts that walks the response post-fetch, hashes seen entry IDs, and either truncates parents past N levels or null-strips circular link fields. The kit ships exactly this in lib/cms/reference-resolver.ts — a few hundred lines of fragile post-processing that has to be kept in sync with every model change.

It is not a one-time cost. Every new content type, every new reference field, every new block can introduce a new path through the cycle. Get a guard wrong and the page either over-fetches into an out-of-memory error, or quietly drops a button's destination so the CTA points at #.

The reference resolver is the single biggest source of subtle production incidents on a REST-only Contentful site. It is also the first thing you can delete when you move the render path to GraphQL.

This is not Contentful-specific. The same cycle shape appears in any CMS where one entry can reference another and one of those entries sits on the page graph. Sanity references, Storyblok story-links, Strapi relations — all of them produce the same graph and require the same kind of post-processing pass on the REST/JSON shape.

The other REST-only cons

  • Over-fetches everything. Every field of every linked entry comes back, even when the page renders three of them. Payload size scales with the model, not with what the user sees.
  • Path resolution requires a 1000-page collection scan. To resolve /parent/child/leaf, the REST SDK has no choice but to fetch every page entry with include: 10 and walk the parent chain in memory. Multi-MB payloads, paginated round-trips, on every uncached request.
  • No server-side locale fallback. The SDK forces a per-locale retry loop on every read: try de-DE, fail, try en-US, succeed. That is one round-trip per attempted locale on cold cache.
  • Coarse cache invalidation. One global revalidateTag('contentful') per webhook cold-caches every page on the site, every time anything changes.
  • Field-level type safety is shallow. The SDK types stop at the response boundary. entry.fields.X is unknown unless you hand-generate types from the CMA — and even then, a model change without a regenerate ships as a runtime error.

GraphQL-only: what works

GraphQL solves the headline REST problems by construction. Selection sets eliminate the cycle (you only get back what you asked for), useFallbackLocale: true moves the locale chain into a single server-side round-trip, and codegen produces typed TypedDocumentNode values that fail the build the moment the model drifts. On the read path, GraphQL is straightforwardly the better tool.

  • Explicit selection sets — cycles cannot form. You only get the fields you ask for, so the response is a tree, not a graph. No reference resolver needed; no post-processing pass; no fragile depth-limiting.
  • useFallbackLocale: true on every selection. Contentful walks the locale chain server-side. One round-trip every time, no per-locale retry loop on the application side.
  • Schema introspection enables real codegen. graphql-codegen emits a TypedDocumentNode per operation with full TS types. Drift between model and code becomes a TypeScript error before it ships.
  • Drift detector catches stale schemas. Compare the live schema hash against the committed snapshot on predev (warn) and prebuild (fail on CI). You ship typed code that matches the live model, every build.
  • Fragment composition stays maintainable. Each block component owns its own .graphql fragment, so the page-level selection set is just ...AllBlocks. The selection set scales with the number of blocks; it does not become an unreadable string template.

GraphQL-only: where it stops working

The case against GraphQL-only is not about read paths. It is about everything that surrounds the read paths — the bundle weight on the client, the brittle parts of GraphQL on Rich Text content, and the fact that the management surface, the upload surface, and the sync surface are all REST.

  • Browser GraphQL clients ship 30–80 KB+ gzip. Apollo, urql, Relay — pick any of them and the cost lands on every page, even routes that do not use GraphQL. On a marketing site this is a measurable Core Web Vitals regression.
  • Hand-authored selections for deep page graphs become brittle string templates. Rich Text fields have a links { ... } stanza that must be selected explicitly, or embedded entries silently disappear from the response. This is a footgun every team rediscovers in production.
  • No alternative for the Sync API. Algolia delta sync via client.sync({ nextSyncToken }) has no GraphQL equivalent. The Contentful spec does not expose it. If you want incremental search indexing, you ship a REST client anyway.
  • No alternative for schema mutations or asset uploads. The CMA is REST/JS only. Migrations, sidebar configuration, multipart-streaming asset uploads — all REST.
  • Live Preview iframes and webhook-driven cache invalidation still flow through REST-style URL paths and tags. The GraphQL response shape does not change that. You end up with REST conventions on the invalidation side regardless.
  • Single-philosophy lock-in. Once you commit GraphQL-only, every call site pays the bundle cost and the selection-set authoring cost — even ones where REST clearly wins (deep page-tree hydration with include: 10, in-app writes, asset uploads).

Why neither alone is the right answer

Pull the threads together. REST loses on the render path because of cycles, payload bloat, and locale loops. GraphQL loses on the write path because the CMA is not GraphQL, on the bundle because the client is heavy, and on sync because the Sync API is not GraphQL either. Each has a strength the other does not.

Every enterprise project that picks one philosophy ends up reimplementing the other half badly. REST-only projects grow a path-resolver caching layer that mimics GraphQL selection sets in code. GraphQL-only projects grow a thin fetch wrapper for CMA writes and a separate Sync indexer. Both end up hybrid by accident — usually after a year of patching.

The argument 'pick one' is an argument about religion, not about engineering. Pick the one that wins per concern, route everything through one fetcher, and the split becomes invisible at every call site.

Decision matrix: pick the right API per job

Map each concern to the API that wins on that concern. The split is not arbitrary — every row below has a clear technical reason it lives where it does.

Decision matrix
Concern                                            | API                          | Why
-------------------------------------------------- | ---------------------------- | ----------------------------------------------
Page / block render path                           | GraphQL CDA                  | Selection sets, unions for contentBlocks, no
                                                   |                              | circular refs, smaller payload
Navigation, site settings, footer (per request)    | GraphQL CDA                  | Hot path, deeply nested — biggest payload win
localizedDictionary (single-entry JSON)            | REST CDA SDK                 | Single round trip, JSON field is API-agnostic
Redirects bulk fetch                               | REST CDA SDK                 | Single content type, single call, runs at edge
Algolia / search index sync                        | REST CDA Sync API            | Purpose-built delta sync — no GraphQL equivalent
Schema migrations                                  | REST CMA + contentful-migration | Only option
In-app editorial writes (form → entry, etc.)      | REST CMA via Server Actions  | Only option; type via contentful-management
Asset uploads                                      | REST CMA Upload API          | Only option; multipart streaming
Live Preview (click-to-edit, real-time updates)    | GraphQL + Live Preview SDK   | Inspector attrs work for both; SDK merges
                                                   |                              | deltas onto GraphQL responses cleanly

Note that localizedDictionary stays on REST. It is a single-entry JSON field that does not benefit from selection sets — one round-trip via either API. The only reason to migrate it would be consistency, and consistency is not a strong enough reason to add migration risk.

Module layout

Keep your existing path aliases (@/cms/*). Reorganize lib/cms/ so each transport has its own file, and the unified cmsFetch wrapper sits underneath both:

Repository layout
lib/cms/
  cma.ts                  // contentful-management client (writes, migrations helpers)
  cda-rest.ts             // REST SDK client (dictionaries, redirects, sync)
  cda-graphql.ts          // typed gql client (page, blocks, navigation, settings)
  fetcher.ts              // unified cmsFetch: timeout + retry + tags + draft swap
  cache.ts                // unstable_cache wrappers, tag conventions
  preview.ts              // existing live-preview helpers (keep as-is)
  reference-resolver.ts   // ONLY used by REST paths; eventually retired

graphql/
  schema.graphql          // committed CDA schema snapshot
  .schema.hash            // SHA-256 used by the drift detector
  fragments/
    BlockHero.fragment.graphql
    BlockFaq.fragment.graphql
    ...
  operations/
    PageBySlug.graphql
    PageByPath.graphql
    NavigationMenu.graphql
    SiteSettings.graphql
  generated/              // graphql-codegen output — committed

The directory structure makes the split visible at a glance: REST lives in cda-rest.ts, GraphQL lives in cda-graphql.ts, and every call to either of them goes through fetcher.ts first.

One cmsFetch wrapper, two transports

Every CMS read should go through one helper so timeout, retry, cache, draft-token swap, and tag conventions stay in one place. The wrapper picks endpoint + token based on api + draft, sets next: { revalidate, tags }, handles 429 with backoff, and emits one structured log line per call.

ts
type CmsFetchOptions = {
  api: 'cda' | 'graphql' | 'cma';
  op: string;                       // logical operation name for logs/metrics
  draft?: boolean;
  tags?: string[];                  // ['contentful', `contentful:type:${id}`, ...]
  revalidate?: number;              // default 60 (seconds)
  timeoutMs?: number;               // default 8000
  retries?: number;                 // 2, jittered backoff on 408 / 425 / 429 / 5xx
};

export async function cmsFetch<T>(
  url: string,
  init: RequestInit & { bearerToken: string },
  options: CmsFetchOptions,
): Promise<T> {
  // — set Authorization: Bearer <token>
  // — wrap in AbortController for timeout
  // — retry RETRYABLE_STATUS with jittered backoff
  // — cache: 'no-store' on draft / dev; otherwise next: { revalidate, tags }
  // — log { api, op, status, durationMs, attempt, tags, requestId, draft }
  // — return parsed JSON
}

The structured log line is gold for support. Every entry includes the operation name, the duration, the cache status, and Contentful's x-contentful-request-id header — which is what their support team will ask for if a query starts misbehaving.

Granular cache tags so editors do not blow the whole cache

The default tag scheme on most Contentful + Next.js setups is one global contentful tag. Every entry edit fires revalidateTag('contentful') and cold-caches every page on the site. That is the worst-case default, and it gets worse as the site grows.

The fix is a fan-out tag scheme derived from the webhook payload:

ts
// Granular tags — derived from Contentful webhook payload
'contentful'                            // global escape hatch
'contentful:type:page'                  // all pages
'contentful:type:navigationMenu'
'contentful:type:siteSettings'
'contentful:entry:<id>'                 // single entry
'contentful:locale:de-DE'               // per-locale invalidation

On entry update, the webhook handler fires both revalidateTag('contentful:entry:<id>') and revalidateTag('contentful:type:<contentType>'). A typo fix in one FAQ item invalidates the FAQ entry tag and the FAQ type tag — not navigation, not site settings, not dictionaries.

GraphQL queries plug into the same scheme. Send via fetch(graphqlUrl, { method: 'POST', body, next: { revalidate, tags } }). Vercel Data Cache deduplicates identical POST bodies, so this works the same as a GET-cached REST call. In Draft Mode, set cache: 'no-store' and switch to the preview token.

Block components own their fragments — the typed-prop pattern

On a REST-only setup, every block component takes block: Record<string, unknown>. That is runtime drift waiting to happen — rename a CMS field and the component compiles fine but renders undefined. With GraphQL + codegen, each block owns its fragment and gets a typed prop.

graphql
# graphql/fragments/BlockHero.fragment.graphql
fragment BlockHero on BlockHero {
  __typename
  sys { id }
  eyebrow
  heading
  body
  primaryCta { ...ButtonLink }
  mediaCollection(limit: 4) {
    items { url width height title }
  }
}
tsx
// components/blocks/HeroSection.tsx
import type { BlockHeroFragment } from '@/graphql/generated';

export function HeroSection({ block }: { block: BlockHeroFragment }) {
  // Every field is typed. Rename in CMS → cf:sync regenerates the type
  // → TypeScript fails the build before the component renders undefined.
}

The page query composes blocks via a union. The __typename selection lets the dispatcher pick the right component:

graphql
query PageBySlug($slug: String!, $locale: String!) {
  pageCollection(
    locale: $locale
    where: { slug: $slug }
    limit: 1
  ) {
    items {
      sys { id }
      slug
      contentBlocksCollection(limit: 30) {
        items {
          __typename
          ...BlockHero
          ...BlockFaq
          ...BlockFeatureGrid
          # 12 fragments total — generated aggregate via _AllBlocks.fragment
        }
      }
    }
  }
}

blockMap becomes a __typename → component lookup with full type inference, and the legacy Record<string, unknown> shape can be deleted.

Multi-locale fallback in one query

On REST, getPageBySlug loops through the locale fallback chain: try de-DE, fail, try en-US, fail, try en. That is up to N round-trips on cold cache.

GraphQL collapses this to one round-trip with aliased queries:

graphql
query PageBySlug($slug: String!) {
  primary: pageCollection(
    locale: "de-DE"
    where: { slug: $slug }
    limit: 1
  ) {
    items { ...PageFull }
  }
  fallback: pageCollection(
    locale: "en-US"
    where: { slug: $slug }
    limit: 1
  ) {
    items { ...PageFull }
  }
}

Server-side, pick primary.items[0] ?? fallback.items[0]. One round-trip, identical caching key. For deeper chains (three or more locales), repeat the alias.

For the getPageByPath case (the 1000-page scan), GraphQL nested where collapses it to a constant-cost query:

graphql
query PageByPath($slug: String!, $parentSlug: String!) {
  pageCollection(
    where: {
      slug: $slug
      parentPage: { slug: $parentSlug }
    }
    limit: 1
  ) {
    items { sys { id } slug }
  }
}

For deeper paths, split the path on / and either chain the where clauses or run a small recursive fetch. Either way, the cost is O(path depth), not O(total pages on the site).

Live Preview survives the migration

Live Preview's data-contentful-* inspector attributes are payload-shape-agnostic. They work for REST responses today; they will work for GraphQL responses tomorrow. You do not have to rewrite the inspector helpers.

tsx
// app/[locale]/layout.tsx (client boundary)
<ContentfulLivePreviewProvider locale={contentfulLocale}>
  {children}
</ContentfulLivePreviewProvider>

In each block (or in a thin wrapper), use useContentfulLiveUpdates(block). The SDK merges editor field updates onto your GraphQL response by matching sys.id and field IDs — the same identifiers REST returns. The migration is invisible to the editor.

Draft Mode handling lives in cda-graphql.ts: when draftMode().isEnabled is true, swap to the Preview API token and set cache: 'no-store'. Same logic as shouldBypassDataCache() in the existing REST client — just routed through the GraphQL path.

Editorial writes via Server Actions (CMA)

For form submissions and any in-app entry creation, wrap the CMA in Server Actions. The CMA is type-safe via contentful-management, and Server Actions give you a clean boundary between the client form and the write surface.

ts
'use server';
import { revalidateTag } from 'next/cache';
import { getCmaClient } from '@/cms/cma';

export async function createLead(input: LeadInput) {
  const env = await getCmaClient()
    .getSpace(process.env.CONTENTFUL_SPACE_ID!)
    .then((s) => s.getEnvironment(process.env.CONTENTFUL_ENVIRONMENT!));

  const entry = await env.createEntry('lead', {
    fields: {
      email: { 'en-US': input.email },
      name:  { 'en-US': input.name },
      // ... other fields, all locale-keyed
    },
  });
  await entry.publish();

  // Read side reacts immediately — same tag contract as cmsFetch.
  revalidateTag('contentful:type:lead');

  return { id: entry.sys.id };
}

CMA never goes through cmsFetch — it has its own client with rate-limit handling and batched mutation support. But it shares the tag invalidation contract, so the read side reacts immediately. That is the only convention that has to be shared between the two transports.

Search index sync stays on the REST Sync API

Algolia, Typesense, and Elastic indexes need delta sync, not snapshot sync. Re-indexing the entire space on every webhook is fine for ten entries; it is not fine for ten thousand. The Sync API is purpose-built for this:

ts
// scripts/index-algolia.ts
const initial = await client.sync({ initial: true });
await persistToken(initial.nextSyncToken);

// On webhook or cron:
const token = await loadToken();
const delta = await client.sync({ nextSyncToken: token });
await algoliaIndex.saveObjects(transform(delta.entries));
await persistToken(delta.nextSyncToken);

There is no GraphQL equivalent. The Contentful spec does not expose nextSyncToken through GraphQL. Run on a Vercel Cron + on the publish webhook for near-real-time index updates, and accept that this part of the system is REST forever.

Resilience defaults inside cmsFetch

These are enterprise table stakes — the difference between a CMS layer that survives a Contentful incident and one that fails the page. All of them live inside cmsFetch, so REST and GraphQL inherit them automatically:

  • Timeout. 8 seconds on the render path, 30 seconds on cron and build. Use AbortController + AbortSignal.any to merge with caller-supplied signals.
  • Retry. 2 attempts with jittered exponential backoff, only on 408 / 425 / 429 / 500 / 502 / 503 / 504. Honour the Retry-After header when present.
  • Circuit breaker. Per-operation flag in Edge Config (or a simple in-memory TTL flag). Opens after N consecutive failures, returns the last-known-good cached payload, closes on a successful probe.
  • Stale-while-error. If Contentful returns 5xx and unstable_cache has a stale entry, serve it. Log the failure for observability; do not fail the page.
  • Per-environment isolation. Separate access tokens per env (master, staging, preview) injected via lib/env.ts. A leaked staging token cannot read production.
  • Observability. Emit cms.request events with { api, op, locale, draft, durationMs, bytes, cacheStatus, requestId }. The GraphQL response's extensions.contentful.requestId is gold for support tickets.

Migration sequencing — phased, value-first

Do not big-bang. The migration is incremental, behind a single env flag, and each phase ships a measurable win. Roughly 10 days of senior work over four phases:

Phase 0 — Foundation (1–2 days)

Add the GraphQL client, codegen, cmsFetch, and draft-token swap. All behind a CMS_USE_GRAPHQL=true env flag. Zero behavioural change in production until you flip it.

Phase 1 — getPageByPath → GraphQL (1 day)

Move path resolution to a PageIndex GraphQL query that returns only { id, slug, parentId }. Walk the parent chain in memory. This kills the 1000-page scan — biggest perf win of the whole migration.

Phase 2 — Layout fan-out (2 days)

Move getNavigationMenu, getNavigationGroups, and getSiteSettings to GraphQL with useFallbackLocale: true. Cuts every-request layout payload by roughly 80% on a typical site.

Phase 3 — Block fragments + getPageBySlug (4–6 days)

Author one fragment per block, compose the page query via ...AllBlocks, switch getPageBySlug over. This is where you get end-to-end type safety on the render path.

Phase 4 — Retire reference-resolver.ts (1 day)

Once the render path is GraphQL, the reference resolver is only used by paths that are explicitly REST (dictionaries, redirects). Delete the GraphQL-touching code paths. Approximately 400 lines of fragile post-processing gone.

Dictionaries, redirects, Algolia sync, and all CMA flows stay on REST forever. That is not a phase — that is the steady state.

The hybrid in one diagram

The whole shape of the system fits in one ASCII diagram. The webhook is the only thing that touches all three transports — it fans out tags that the read side reacts to:

Architecture
                    +-----------------------------------------+
                    |    Webhook -> /api/revalidate           |
                    |  fans out per-entry & per-type tags     |
                    +--------------------+--------------------+
                                         |
                                         v
                       revalidateTag('contentful:*')
                                         |
       +---------------------------------+---------------------------------+
       |                                 |                                 |
       v                                 v                                 v
   GraphQL CDA                      REST CDA SDK                       REST CMA
   (render path)                 (dictionaries,                     (server actions,
   - Pages                        redirects)                         migrations,
   - Navigation                                                      asset uploads)
   - Settings                    REST CDA Sync
   - 12 block fragments          (Algolia indexer)
   - Live Preview
       |
       v
   Vercel Data Cache + unstable_cache
   (tags: contentful:type:*, contentful:entry:*, contentful:locale:*)

What you get: the smallest payloads on the hot path, full type safety on render, REST where it is actually superior (dictionaries, sync, writes), one cache invalidation contract, Live Preview unchanged, and a clear deletion target (reference-resolver.ts) once the migration is done.

This pattern is not Contentful-specific

The same split — selection-set transport for the render hot path, full-payload SDK for sync and writes, one fetcher underneath — works in Sanity, Storyblok, Strapi, or any headless CMS that exposes both a delivery API and a management API. The point is structural, not vendor-specific.
What to actually do
  • REST-only on a multi-locale CMS site means circular references on every full page fetch — the post-processor that fixes them is a permanent maintenance tax that grows with the model.
  • GraphQL-only means a 30–80 KB client on every browser page, plus rebuilding the Sync API and CMA flows you didn't need to touch.
  • The right split is by concern: GraphQL on the render hot path (cycles + bundles + locale + types all win), REST CDA SDK for dictionaries / redirects / Sync, REST CMA for migrations + writes + assets.
  • Route everything through one cmsFetch wrapper with a shared cache-tag contract so the split is invisible at call sites.
  • Migrate in phases — getPageByPath first (kills the 1000-page scan), then layout fan-out, then block fragments. Don't big-bang; ship the foundation behind an env flag.
  • End state: smallest payloads on the hot path, type-safe render end-to-end, REST where it's actually best, Live Preview unchanged, and a clear deletion target for the legacy reference resolver.

Want this kind of judgment on your project?

I read every email within one working day. Bring a project, a quote, or a system you're stuck on.

Book a 30-min callSee the Enterprise Starter
Related guides
  • 9 min

    15 things every Contentful enterprise project gets wrong in the first 6 weeks

    The 15 production gaps every enterprise Contentful + Next.js build hits in the first six weeks — and how to close each one without burning a sprint. A pre-kickoff checklist for technical leads on a Contentful enterprise starter.

  • 3 min

    CMS-driven analytics: stop paying developers for every tracking change

    How to structure your CMS so marketers can add tracking events without developer intervention. Save tens of thousands in ongoing costs.

  • 5 min

    Dynamic CMS linking: why your 'Link' field is a time bomb

    Most CMS implementations let authors paste raw URLs. The day someone renames a slug, half your navigation silently 404s. The model that makes that impossible — with a Contentful walkthrough.