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.
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 tilen-USog endeligten. 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: 10i é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:
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 cyklusDet 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/leafhar REST-SDK'et intet andet valg end at hente hver side-entry medinclude: 10og 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øven-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.Xerunknown, 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: truepå 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-codegenemitterer enTypedDocumentNodepr. 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) ogprebuild(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.
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 rentBemæ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:
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 — committedMappestrukturen 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.
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:
// 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-invalideringVed 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/fragments/BlockHero.fragment.graphql
fragment BlockHero on BlockHero {
__typename
sys { id }
eyebrow
heading
body
primaryCta { ...ButtonLink }
mediaCollection(limit: 4) {
items { url width height title }
}
}// 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:
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:
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:
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.
// 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.
'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:
// 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.anytil 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_cachehar en stale entry, server den. Log fejlen for observability; fejl ikke siden. - Pr.-environment-isolation. Separate access-tokens pr. env (
master,staging,preview) injiceret vialib/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-svaretsextensions.contentful.requestIder 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å:
+-----------------------------------------+
| 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
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.
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.
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.
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.