SystemetRådgivningUdviklingArbejdePriser
Tilbage til guides
13 min. · CMS & INDHOLD

REST + GraphQL-hybrider til multi-locale CMS-drevne sites

Kun REST på et multi-locale CMS-site betyder cirkulære referencer ved hver fuld side-fetch — og en skrøbelig reference-resolver-pass til at rydde dem op. Kun GraphQL betyder en 30–80 KB-klient på hver browser-side, plus genopbygning af de Sync API- og CMA-flows, du ikke behøvede at røre. Det rigtige svar er en hybrid splittet efter ansvar, ikke efter API.

På denne side
  • De seks søjler i et multi-locale CMS-drevet site
  • Kun REST: hvad der virker
  • Kun REST: hvor det brækker (og hvorfor cirkulære referencer er overskriften)
  • Kun GraphQL: hvad der virker
  • Kun GraphQL: hvor det stopper med at virke
  • Hvorfor hverken alene er det rigtige svar
  • Beslutningsmatrice: vælg den rigtige API pr. opgave
  • Modul-layout
  • Én cmsFetch-wrapper, to transports
  • Granulære cache-tags så redaktørerne ikke blæser hele cachen
  • Blok-komponenter ejer deres fragmenter — det typede prop-mønster
  • Multi-locale-fallback i én query
  • Live Preview overlever migrationen
  • Redaktionelle skrivninger via Server Actions (CMA)
  • Search-index-sync bliver på REST Sync API
  • Resilience-defaults inde i cmsFetch
  • Migrationsrækkefølge — faseopdelt, value-first
  • Hybriden i ét diagram
Opdateret 2026-04
TL;DR
  • Kun REST er ét modent SDK plus dybe include: N-grafer — men en enkelt side-anmodning returnerer en graf med cykler (forælder → barn → knap → forælder), hvilket tvinger skrøbelig efterbehandling med reference-stripping.
  • Kun GraphQL dræber cyklerne by design med eksplicitte selection sets og giver dig typede dokumenter — men det sender en 30–80 KB-klient på hver browser-side og har intet svar til sync-API'er, skema-migrations, asset-uploads eller in-app-skrivninger.
  • Multi-locale CMS-drevne sites har seks distinkte ansvar (render-graf, locale-fallback, hot-path layout-fan-out, skriveoverflade, preview/draft-flow, search-index-sync). Ingen enkelt API vinder alle seks.
  • Det rigtige svar er en hybrid splittet efter ansvar, ikke efter API: GraphQL CDA på render-hot-pathen, REST CDA SDK til sync + dictionaries + redirects, REST CMA til migrations + skrivninger + asset-uploads, alle routet gennem én cmsFetch-wrapper med en delt cache-tag-kontrakt.
  • Migrér i faser — getPageByPath først (dræber 1000-siders-scannet), så layout-fan-out, så blok-fragmenter. Cirka 10 dages senior-arbejde, value-first, med et klart sletningsmål for den gamle reference-resolver til sidst.

De seks søjler i et multi-locale CMS-drevet site

Hvert enterprise multi-locale CMS-site, jeg har shippet — Contentful, Sanity, Storyblok, Strapi — løber ind i de samme seks ansvar. De skal alle besvares. De er meget forskellige problemer, og ingen enkelt API-overflade er bedst til alle seks.

  • Dyb render-graf. Sider refererer blokke; blokke refererer kort, assets, CTA'er; CTA'er refererer interne sider; interne sider refererer forælder-sider. Den fulde payload for én side er en graf, ikke et træ, og den krydser de samme noder mere end én gang.
  • Locale-fallback-kæde. Hver read kan målrettes de-DE, falde tilbage til en-US og endeligt en. Hvis det gøres klient-side, betyder det N round-trips på cold cache, hvor N er kædens dybde.
  • Hot-path layout-fan-out. Hver request — hver side — skal bruge site settings, navigation og dictionaries før render. Hvis de reads er over-fetched eller kædet, betaler hver side på sitet for det.
  • Skriveoverflade. Form-indsendelser, kommentarer, lead capture, in-app entry-oprettelse, asset-uploads. Alle rammer Management API'en, der ikke har en GraphQL-overflade nogen steder, jeg har set.
  • Preview/draft-flow. Redaktøren har brug for click-to-edit, real-time-opdateringer, draft-tokens og en separat cache-disciplin. Read-API'en skal skifte transparent mellem preview og delivery uden at omskrive kaldstederne.
  • Search-index-sync. Algolia, Typesense, Elastic — de har alle brug for en delta-sync-API for at holde sig opdaterede uden at re-indeksere hele spacet ved hvert webhook. Den delta-sync-API er purpose-built REST. Der er ingen GraphQL-ækvivalent.

Ingen enkelt API giver dig et rent svar på alle seks. Det er derfor, hver enterprise CMS-implementation, der faktisk shipper, ender hybrid — som regel ved et uheld, efter måneders patching. Den ærlige version er at designe hybriden fra start.

Kun REST: hvad der virker

REST er det oplagte startpunkt. Vendor-SDK'et er modent, URL-caching-historien er velkendt, og ét dybt include: N-kald returnerer et brugbart svar for den simple form (én entry, én render). For små sites shipper kun-REST og ældes godt.

  • Ét SDK. contentful-pakken giver dig én auth-model, én mental model, én fejl-overflade. Nye udviklere kan shippe en feature på dag ét.
  • Dyb include: 10 i ét kald. Når siden faktisk er et træ (én entry, der renderer én komponent), returnerer en dyb include hele grafen i ét round-trip. At hånd-skrive den tilsvarende GraphQL-selection-set ville være kedelig.
  • Moden CDN-caching pr. URL. REST-endpoints er cacheable pr. URL — hver reverse proxy, hver edge-cache, hver browser ved, hvad den skal gøre.
  • REST CMA + REST Sync API er førsteklasses. Skema-migrations, sidebar-konfiguration, asset-uploads og Algolia delta-sync lever alle på management API'en eller sync-API'en — begge REST. Du behøver ikke shippe en ekstra klient bare for at understøtte de flows.

Kun REST: hvor det brækker (og hvorfor cirkulære referencer er overskriften)

Overskriftsulempen ved en kun-REST Contentful- (eller Sanity- eller Storyblok-) implementation er ikke båndbredde. Det er ikke engang typesikkerhed. Det er cirkulære referencer på fulde side-payloads, og vedligeholdelses-skatten på den efterbehandlings-pass, der skal rydde op.

Cyklens form

Forestil dig en enterprise-indholdsmodel. En Page har en parentPage (self-referential — sider lever i et hierarki). En Page's contentBlocks indeholder en BlockHero. BlockHero har et primaryCta-felt, typet som en ButtonLink. ButtonLink har et internalLink-felt — en reference til en Page. Den mål-Page kan være den samme side (et self-link) eller en anden side, der også har en parentPage og CTA'er.

Spørg nu REST CDA'en om siden med include: 10. SDK'et gennemløber gladeligt hver reference indtil dybde 10 og returnerer en graf, browseren ikke kan serialisere uden uendelig traversering:

Reference-graf (cyklus)
Page (slug: about)
  ├── parentPage → Page (slug: company)
  │     └── parentPage → Page (slug: home)
  │           └── parentPage → null
  └── contentBlocks
        ├── BlockHero
        │     └── primaryCta → ButtonLink
        │           └── internalLink → Page (slug: about)   ← cyklus tilbage til roden
        │                 └── parentPage → Page (slug: company)
        │                       └── ... include-dybde fortsætter
        └── BlockFeatureGrid
              └── cards[].link → ButtonLink
                    └── internalLink → Page (slug: pricing)
                          └── contentBlocks[0] = BlockHero
                                └── primaryCta → ButtonLink
                                      └── internalLink → Page (slug: about)  ← endnu en cyklus

Det er ikke patologisk indholdsmodellering. Det er, hvordan hvert reelt site ser ud, så snart forfattere kan placere CTA'er på sider, og sider lever i et hierarki. Cyklen findes i modellen. REST-API'en eksponerer den uden kommentar.

Den konventionelle løsning (og hvorfor den er en permanent skat)

Hvert REST-formet Contentful-projekt ender med en reference-resolver.ts, der gennemløber svaret efter hentning, hasher sete entry-ID'er og enten afkorter forældre forbi N niveauer eller null-stripper cirkulære link-felter. Kittet leverer præcis dette i lib/cms/reference-resolver.ts — et par hundrede linjer skrøbelig efterbehandling, der skal holdes synkroniseret med hver modelændring.

Det er ikke en engangsomkostning. Hver ny indholdstype, hvert nyt reference-felt, hver ny blok kan introducere en ny vej gennem cyklen. Få et guard-tjek forkert, og siden enten over-fetcher til en out-of-memory-fejl eller dropper i stilhed en knaps destination, så CTA'en peger på #.

Reference-resolveren er den enkelt største kilde til subtile produktionshændelser på et kun-REST Contentful-site. Den er også det første, du kan slette, når du flytter render-pathen til GraphQL.

Det her er ikke Contentful-specifikt. Den samme cyklus-form opstår i hvert CMS, hvor én entry kan referere en anden, og en af dem ligger på sidegrafen. Sanity-references, Storyblok-story-links, Strapi-relations — alle sammen producerer den samme graf og kræver samme slags efterbehandling på REST/JSON-formen.

De øvrige kun-REST-ulemper

  • Over-fetcher alt. Hvert felt på hver linket entry kommer tilbage, også når siden renderer tre af dem. Payload-størrelsen skalerer med modellen, ikke med det, brugeren ser.
  • Path-resolution kræver et 1000-siders kollektion-scan. For at resolve /parent/child/leaf har REST-SDK'et intet andet valg end at hente hver side-entry med include: 10 og gennemløbe forælder-kæden i hukommelsen. Multi-MB payloads, paginerede round-trips, ved hver ucached request.
  • Ingen server-side locale-fallback. SDK'et tvinger en pr.-locale-retry-loop på hver read: prøv de-DE, fejl, prøv en-US, succes. Det er én round-trip pr. forsøgt locale på cold cache.
  • Grov cache-invalidering. Én global revalidateTag('contentful') pr. webhook cold-cacher hver side på sitet, hver gang noget ændrer sig.
  • Felt-niveau type-sikkerhed er overfladisk. SDK-typerne stopper ved response-grænsen. entry.fields.X er unknown, medmindre du hånd-genererer typer fra CMA'en — og selv da shipper en modelændring uden regenerering som en runtime-fejl.

Kun GraphQL: hvad der virker

GraphQL løser overskrifts-REST-problemerne by design. Selection sets eliminerer cyklen (du får kun det tilbage, du beder om), useFallbackLocale: true flytter locale-kæden ind i ét server-side round-trip, og codegen producerer typede TypedDocumentNode-værdier, der fejler bygget i det øjeblik, modellen drifter. På read-pathen er GraphQL ligeud det bedre værktøj.

  • Eksplicitte selection sets — cykler kan ikke opstå. Du får kun de felter, du beder om, så svaret er et træ, ikke en graf. Ingen reference-resolver nødvendig; ingen efterbehandlings-pass; ingen skrøbelig dybde-begrænsning.
  • useFallbackLocale: true på hver selection. Contentful gennemløber locale-kæden server-side. Ét round-trip hver gang, ingen pr.-locale-retry-loop på applikationssiden.
  • Skema-introspektion muliggør reel codegen. graphql-codegen emitterer en TypedDocumentNode pr. operation med fulde TS-typer. Drift mellem model og kode bliver en TypeScript-fejl, før det shipper.
  • Drift-detektor fanger forældede skemaer. Sammenlign det live skema-hash med det committede snapshot på predev (advarsel) og prebuild (fejl på CI). Du shipper typet kode, der matcher den live model, ved hvert byg.
  • Fragment-komposition forbliver vedligeholdelsesvenlig. Hver blok-komponent ejer sit eget .graphql-fragment, så side-niveau-selection-set'et er bare ...AllBlocks. Selection set'et skalerer med antallet af blokke; det bliver ikke en ulæselig string-template.

Kun GraphQL: hvor det stopper med at virke

Argumentet mod kun-GraphQL handler ikke om read-paths. Det handler om alt det, der omgiver read-paths — bundle-vægten på klienten, de skrøbelige dele af GraphQL på Rich Text-indhold og det faktum, at management-overfladen, upload-overfladen og sync-overfladen alle er REST.

  • Browser-GraphQL-klienter sender 30–80 KB+ gzip. Apollo, urql, Relay — vælg en af dem, og omkostningen lander på hver side, også ruter der ikke bruger GraphQL. På et marketing-site er det en målbar Core Web Vitals-regression.
  • Hånd-skrevne selections til dybe sidegrafer bliver skrøbelige string-templates. Rich Text-felter har en links { ... }-stanza, der skal vælges eksplicit, ellers forsvinder embedded entries i stilhed fra svaret. Det er en fodgun, hvert team genopdager i produktion.
  • Intet alternativ til Sync API'en. Algolia delta-sync via client.sync({ nextSyncToken }) har ingen GraphQL-ækvivalent. Contentful-spec'en eksponerer den ikke. Hvis du vil have inkrementel søge-indeksering, shipper du en REST-klient alligevel.
  • Intet alternativ til skema-mutationer eller asset-uploads. CMA er kun REST/JS. Migrations, sidebar-konfiguration, multipart-streaming asset-uploads — alt sammen REST.
  • Live Preview-iframes og webhook-styret cache-invalidering flyder stadig gennem REST-stil URL-paths og tags. GraphQL-svar-formen ændrer ikke det. Du ender med REST-konventioner på invaliderings-siden uanset.
  • Single-philosophy lock-in. Når du først har committet til kun-GraphQL, betaler hvert kaldsted bundle-omkostningen og selection-set-skrivningsomkostningen — også dem, hvor REST klart vinder (dyb side-tree-hydrering med include: 10, in-app-skrivninger, asset-uploads).

Hvorfor hverken alene er det rigtige svar

Træk trådene sammen. REST taber på render-pathen på grund af cykler, payload-bloat og locale-loops. GraphQL taber på skrive-pathen, fordi CMA ikke er GraphQL, på bundlet fordi klienten er tung, og på sync fordi Sync API'en heller ikke er GraphQL. Hver har en styrke, den anden ikke har.

Hvert enterprise-projekt, der vælger én filosofi, ender med at genimplementere den anden halvdel dårligt. Kun-REST-projekter vokser et path-resolver-caching-lag, der efterligner GraphQL-selection-sets i kode. Kun-GraphQL-projekter vokser en tynd fetch-wrapper til CMA-skrivninger og en separat Sync-indekserer. Begge ender hybrid ved et uheld — som regel efter et års patching.

Argumentet 'vælg én' er et argument om religion, ikke om engineering. Vælg den, der vinder pr. ansvar, route alt gennem én fetcher, og splittet bliver usynligt på hvert kaldsted.

Beslutningsmatrice: vælg den rigtige API pr. opgave

Map hvert ansvar til den API, der vinder på det ansvar. Splittet er ikke vilkårligt — hver række nedenfor har en klar teknisk grund til, at den ligger der, hvor den ligger.

Beslutningsmatrice
Ansvar                                             | API                          | Hvorfor
-------------------------------------------------- | ---------------------------- | ----------------------------------------------
Side- / blok-render-path                           | GraphQL CDA                  | Selection sets, unions for contentBlocks, ingen
                                                   |                              | cirkulære refs, mindre payload
Navigation, site settings, footer (pr. request)    | GraphQL CDA                  | Hot path, dybt nestet — største payload-gevinst
localizedDictionary (single-entry JSON)            | REST CDA SDK                 | Ét round-trip, JSON-felt er API-agnostisk
Redirects bulk-fetch                               | REST CDA SDK                 | Én indholdstype, ét kald, kører på edge
Algolia / search-index-sync                        | REST CDA Sync API            | Purpose-built delta-sync — ingen GraphQL-ækvivalent
Skema-migrations                                   | REST CMA + contentful-migration | Eneste mulighed
In-app redaktionelle skrivninger (form → entry)   | REST CMA via Server Actions  | Eneste mulighed; type via contentful-management
Asset-uploads                                      | REST CMA Upload API          | Eneste mulighed; multipart-streaming
Live Preview (click-to-edit, real-time-opdat.)     | GraphQL + Live Preview SDK   | Inspector-attrs virker for begge; SDK'et merger
                                                   |                              | deltas på GraphQL-svar rent

Bemærk at localizedDictionary bliver på REST. Det er et single-entry JSON-felt, der ikke får gavn af selection sets — ét round-trip via begge API'er. Den eneste grund til at migrere det ville være konsistens, og konsistens er ikke en stærk nok grund til at tilføje migrationsrisiko.

Modul-layout

Behold dine eksisterende path-aliaser (@/cms/*). Reorganiser lib/cms/ så hver transport har sin egen fil, og den samlede cmsFetch-wrapper sidder under begge:

Repo-layout
lib/cms/
  cma.ts                  // contentful-management-klient (skrivninger, migration-helpers)
  cda-rest.ts             // REST SDK-klient (dictionaries, redirects, sync)
  cda-graphql.ts          // typet gql-klient (page, blocks, navigation, settings)
  fetcher.ts              // samlet cmsFetch: timeout + retry + tags + draft-swap
  cache.ts                // unstable_cache-wrappers, tag-konventioner
  preview.ts              // eksisterende live-preview-helpers (behold)
  reference-resolver.ts   // KUN brugt af REST-paths; pensioneres til sidst

graphql/
  schema.graphql          // committed CDA-skema-snapshot
  .schema.hash            // SHA-256 brugt af drift-detektoren
  fragments/
    BlockHero.fragment.graphql
    BlockFaq.fragment.graphql
    ...
  operations/
    PageBySlug.graphql
    PageByPath.graphql
    NavigationMenu.graphql
    SiteSettings.graphql
  generated/              // graphql-codegen-output — committed

Mappestrukturen gør splittet synligt: REST lever i cda-rest.ts, GraphQL lever i cda-graphql.ts, og hvert kald til begge går gennem fetcher.ts først.

Én cmsFetch-wrapper, to transports

Hvert CMS-read bør gå gennem én helper, så timeout, retry, cache, draft-token-swap og tag-konventioner bliver ét sted. Wrapperen vælger endpoint + token baseret på api + draft, sætter next: { revalidate, tags }, håndterer 429 med backoff og emitterer én struktureret log-linje pr. kald.

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

export async function cmsFetch<T>(
  url: string,
  init: RequestInit & { bearerToken: string },
  options: CmsFetchOptions,
): Promise<T> {
  // — sæt Authorization: Bearer <token>
  // — wrap i AbortController for timeout
  // — retry RETRYABLE_STATUS med jittered backoff
  // — cache: 'no-store' på draft / dev; ellers next: { revalidate, tags }
  // — log { api, op, status, durationMs, attempt, tags, requestId, draft }
  // — returnér parset JSON
}

Den strukturerede log-linje er guld for support. Hver entry inkluderer operation-navnet, varigheden, cache-status og Contentfuls x-contentful-request-id-header — som er det, deres support-team vil bede om, hvis en query begynder at opføre sig sært.

Granulære cache-tags så redaktørerne ikke blæser hele cachen

Default-tag-skemaet på de fleste Contentful + Next.js-opsætninger er ét globalt contentful-tag. Hver entry-redigering fyrer revalidateTag('contentful') og cold-cacher hver side på sitet. Det er den værst-tænkelige default, og det bliver værre, efterhånden som sitet vokser.

Løsningen er et fan-out-tag-skema afledt af webhook-payloaden:

ts
// Granulære tags — afledt af Contentful-webhook-payload
'contentful'                            // global escape hatch
'contentful:type:page'                  // alle sider
'contentful:type:navigationMenu'
'contentful:type:siteSettings'
'contentful:entry:<id>'                 // enkelt entry
'contentful:locale:de-DE'               // pr.-locale-invalidering

Ved entry-opdatering fyrer webhook-handleren både revalidateTag('contentful:entry:<id>') og revalidateTag('contentful:type:<contentType>'). En typo-rettelse i én FAQ-item invaliderer FAQ-entry-tagget og FAQ-type-tagget — ikke navigation, ikke site settings, ikke dictionaries.

GraphQL-queries kobles ind i samme skema. Send via fetch(graphqlUrl, { method: 'POST', body, next: { revalidate, tags } }). Vercel Data Cache deduplikerer identiske POST-bodies, så dette virker på samme måde som et GET-cached REST-kald. I Draft Mode, sæt cache: 'no-store' og skift til preview-tokenet.

Blok-komponenter ejer deres fragmenter — det typede prop-mønster

På en kun-REST-opsætning tager hver blok-komponent block: Record<string, unknown>. Det er runtime-drift, der venter på at ske — omdøb et CMS-felt, og komponenten compiler fint, men renderer undefined. Med GraphQL + codegen ejer hver blok sit fragment og får et typet 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 }) {
  // Hvert felt er typet. Omdøb i CMS → cf:sync regenererer typen
  // → TypeScript fejler bygget, før komponenten renderer undefined.
}

Side-querien komponerer blokkene via en union. __typename-selectionen lader dispatcheren vælge den rigtige komponent:

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 fragmenter i alt — genereret aggregat via _AllBlocks.fragment
        }
      }
    }
  }
}

blockMap bliver et __typename → component-opslag med fuld type-inferens, og den gamle Record<string, unknown>-form kan slettes.

Multi-locale-fallback i én query

På REST loop'er getPageBySlug gennem locale-fallback-kæden: prøv de-DE, fejl, prøv en-US, fejl, prøv en. Det er op til N round-trips på cold cache.

GraphQL kollapser dette til ét round-trip med aliaserede 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, vælg primary.items[0] ?? fallback.items[0]. Ét round-trip, identisk caching-nøgle. For dybere kæder (tre eller flere locales), gentag aliaset.

For getPageByPath-tilfældet (1000-siders-scannet) kollapser GraphQL nested where det til en konstant-cost-query:

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

For dybere paths, split path'en på / og enten kæd where-klausulerne eller kør et lille rekursivt fetch. Uanset hvad er omkostningen O(path-dybde), ikke O(samlet antal sider på sitet).

Live Preview overlever migrationen

Live Previews data-contentful-*-inspector-attributter er payload-form-agnostiske. De virker for REST-svar i dag; de vil virke for GraphQL-svar i morgen. Du behøver ikke omskrive inspector-helperne.

tsx
// app/[locale]/layout.tsx (klient-grænse)
<ContentfulLivePreviewProvider locale={contentfulLocale}>
  {children}
</ContentfulLivePreviewProvider>

I hver blok (eller i en tynd wrapper), brug useContentfulLiveUpdates(block). SDK'et merger redaktørens felt-opdateringer på dit GraphQL-svar ved at matche sys.id og felt-ID'er — de samme identifiers, REST returnerer. Migrationen er usynlig for redaktøren.

Draft Mode-håndtering lever i cda-graphql.ts: når draftMode().isEnabled er true, skift til Preview API-tokenet og sæt cache: 'no-store'. Samme logik som shouldBypassDataCache() i den eksisterende REST-klient — bare routet gennem GraphQL-pathen.

Redaktionelle skrivninger via Server Actions (CMA)

Til form-indsendelser og enhver in-app entry-oprettelse skal du wrappe CMA i Server Actions. CMA er typesikker via contentful-management, og Server Actions giver dig en ren grænse mellem klient-formen og skrive-overfladen.

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 },
      // ... øvrige felter, alle locale-keyed
    },
  });
  await entry.publish();

  // Read-side reagerer øjeblikkeligt — samme tag-kontrakt som cmsFetch.
  revalidateTag('contentful:type:lead');

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

CMA går aldrig gennem cmsFetch — den har sin egen klient med rate-limit-håndtering og batched mutation-support. Men den deler tag-invaliderings-kontrakten, så read-siden reagerer øjeblikkeligt. Det er den eneste konvention, der skal deles mellem de to transports.

Search-index-sync bliver på REST Sync API

Algolia-, Typesense- og Elastic-indekser har brug for delta-sync, ikke snapshot-sync. At re-indeksere hele spacet ved hvert webhook er fint for ti entries; det er ikke fint for ti tusinde. Sync API'en er purpose-built til dette:

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

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

Der er ingen GraphQL-ækvivalent. Contentful-spec'en eksponerer ikke nextSyncToken via GraphQL. Kør på en Vercel Cron + på publish-webhook for near-real-time-indeks-opdateringer, og acceptér at denne del af systemet er REST for evigt.

Resilience-defaults inde i cmsFetch

Det er enterprise-table-stakes — forskellen mellem et CMS-lag, der overlever en Contentful-incident, og et, der fejler siden. Alle bor inde i cmsFetch, så REST og GraphQL arver dem automatisk:

  • Timeout. 8 sekunder på render-pathen, 30 sekunder på cron og byg. Brug AbortController + AbortSignal.any til at merge med caller-leverede signaler.
  • Retry. 2 forsøg med jittered eksponentiel backoff, kun på 408 / 425 / 429 / 500 / 502 / 503 / 504. Respekter Retry-After-headeren, når den er til stede.
  • Circuit breaker. Pr.-operation-flag i Edge Config (eller et simpelt in-memory TTL-flag). Åbner efter N på hinanden følgende fejl, returnerer den sidste-kendte-gode cached payload, lukker ved en succesfuld probe.
  • Stale-while-error. Hvis Contentful returnerer 5xx, og unstable_cache har en stale entry, server den. Log fejlen for observability; fejl ikke siden.
  • Pr.-environment-isolation. Separate access-tokens pr. env (master, staging, preview) injiceret via lib/env.ts. Et lækket staging-token kan ikke læse production.
  • Observability. Emit cms.request-events med { api, op, locale, draft, durationMs, bytes, cacheStatus, requestId }. GraphQL-svarets extensions.contentful.requestId er guld for support-tickets.

Migrationsrækkefølge — faseopdelt, value-first

Lav ikke big-bang. Migrationen er inkrementel, bag et enkelt env-flag, og hver fase shipper en målbar gevinst. Cirka 10 dages senior-arbejde over fire faser:

Fase 0 — Fundament (1–2 dage)

Tilføj GraphQL-klienten, codegen, cmsFetch og draft-token-swap. Alt bag et CMS_USE_GRAPHQL=true-env-flag. Nul adfærdsændring i production indtil du flipper det.

Fase 1 — getPageByPath → GraphQL (1 dag)

Flyt path-resolution til en PageIndex GraphQL-query, der kun returnerer { id, slug, parentId }. Gennemløb forælder-kæden i hukommelsen. Det dræber 1000-siders-scannet — den største perf-gevinst i hele migrationen.

Fase 2 — Layout-fan-out (2 dage)

Flyt getNavigationMenu, getNavigationGroups og getSiteSettings til GraphQL med useFallbackLocale: true. Skærer hver-request layout-payload med cirka 80% på et typisk site.

Fase 3 — Blok-fragmenter + getPageBySlug (4–6 dage)

Skriv ét fragment pr. blok, komponér side-querien via ...AllBlocks, skift getPageBySlug over. Det er her du får end-to-end-typesikkerhed på render-pathen.

Fase 4 — Pensionér reference-resolver.ts (1 dag)

Når render-pathen er GraphQL, bruges reference-resolveren kun af paths, der eksplicit er REST (dictionaries, redirects). Slet de GraphQL-rørende kode-paths. Cirka 400 linjer skrøbelig efterbehandling væk.

Dictionaries, redirects, Algolia-sync og alle CMA-flows bliver på REST for evigt. Det er ikke en fase — det er steady state.

Hybriden i ét diagram

Hele systemets form passer i ét ASCII-diagram. Webhooken er det eneste, der rører alle tre transports — den fanner tags ud, som read-siden reagerer på:

Arkitektur
                    +-----------------------------------------+
                    |    Webhook -> /api/revalidate           |
                    |  fanner pr.-entry- & pr.-type-tags ud   |
                    +--------------------+--------------------+
                                         |
                                         v
                       revalidateTag('contentful:*')
                                         |
       +---------------------------------+---------------------------------+
       |                                 |                                 |
       v                                 v                                 v
   GraphQL CDA                      REST CDA SDK                       REST CMA
   (render-path)                 (dictionaries,                     (server actions,
   - Sider                        redirects)                         migrations,
   - Navigation                                                      asset-uploads)
   - Settings                    REST CDA Sync
   - 12 blok-fragmenter          (Algolia-indekserer)
   - Live Preview
       |
       v
   Vercel Data Cache + unstable_cache
   (tags: contentful:type:*, contentful:entry:*, contentful:locale:*)

Hvad du får: de mindste payloads på hot-pathen, fuld typesikkerhed på render, REST hvor det faktisk er overlegent (dictionaries, sync, skrivninger), én cache-invaliderings-kontrakt, Live Preview uændret og et klart sletningsmål (reference-resolver.ts) når migrationen er færdig.

Det her mønster er ikke Contentful-specifikt

Det samme split — selection-set-transport til render-hot-pathen, full-payload SDK til sync og skrivninger, én fetcher under — virker i Sanity, Storyblok, Strapi eller hvert headless CMS, der eksponerer både en delivery-API og en management-API. Pointen er strukturel, ikke vendor-specifik.
What to actually do
  • Kun REST på et multi-locale CMS-site betyder cirkulære referencer ved hver fuld side-fetch — efterbehandleren der retter dem er en permanent vedligeholdelses-skat, der vokser med modellen.
  • Kun GraphQL betyder en 30–80 KB-klient på hver browser-side, plus genopbygning af de Sync API- og CMA-flows, du ikke behøvede at røre.
  • Det rigtige split er efter ansvar: GraphQL på render-hot-pathen (cykler + bundles + locale + typer vinder alle), REST CDA SDK til dictionaries / redirects / Sync, REST CMA til migrations + skrivninger + assets.
  • Route alt gennem én cmsFetch-wrapper med en delt cache-tag-kontrakt, så splittet bliver usynligt på kaldsteder.
  • Migrér i faser — getPageByPath først (dræber 1000-siders-scannet), så layout-fan-out, så blok-fragmenter. Lav ikke big-bang; ship fundamentet bag et env-flag.
  • Slut-tilstand: mindste payloads på hot-pathen, typesikker render fra ende til anden, REST hvor det faktisk er bedst, Live Preview uændret og et klart sletningsmål for den gamle reference-resolver.

Vil du have denne slags vurdering på dit projekt?

Jeg læser hver e-mail inden for én arbejdsdag. Tag et projekt, et tilbud eller et system, du sidder fast i.

Se Enterprise Starter
Relaterede guides
  • 9 min.

    15 ting hvert Contentful enterprise-projekt får galt i de første 6 uger

    De 15 produktions-huller hvert enterprise Contentful + Next.js-build rammer i de første seks uger — og hvordan du lukker hver enkelt uden at brænde et sprint. En pre-kickoff-checkliste til tech-leads på en Contentful enterprise starter.

  • 3 min.

    CMS-styret analyse: stop med at betale udviklere for hver tracking-ændring

    Sådan strukturerer du dit CMS, så marketing kan tilføje tracking-hændelser uden udviklerindblanding. Spar sekscifrede DKK-beløb over sitets levetid.

  • 5 min.

    Dynamiske CMS-links: hvorfor dit 'Link'-felt er en tidsbombe

    De fleste CMS-implementeringer lader forfattere indsætte rå URL'er. Den dag, nogen omdøber en slug, 404'er halvdelen af din navigation i stilhed. Modellen der gør det umuligt — med en Contentful-gennemgang.

Uafhængig teknologirådgivning og udvikling. København, Danmark.

Njalsgade 21F, 2. sal, København

CVR 45 44 13 93

nicklas@ceero.eu

WhatsApp +45 31 33 25 99

Arbejde

  • Enterprise Starter
  • Rådgivning
  • Udvikling
  • Arbejde
  • Kontakt

Ressourcer

  • Artikler
  • Guides
  • Gratis værktøjer
  • Om os

Andre steder

  • Sparro
  • Invigilo
  • e-sign
  • Privatlivspolitik

© 2026 Ceero ApS · CVR DK45441393. Alle rettigheder forbeholdes.

Bygget i København med Next.js, Contentful og nul konsulent-bullshit.