Et klassisk CMS leverer både indholdet og siderne. Det er bekvemt i starten, men det binder præsentationen til CMS'ets skabeloner, plugins og release-cyklus. Når designet skal ændres, eller når en side skal performe bedre, ender man med at arbejde inde i CMS'et i stedet for med det.
Lad os tage et konkret eksempel: den fiktive virksomhed Space Travel skal have et nyt website på space-travel.example. Arkitekturen er headless: Strapi som CMS, der kun leverer indhold via et API, og Next.js som en selvstændig frontend, der henter indholdet og bygger siderne. Virksomheden og navnene er opdigtede, men valgene og erfaringerne kommer fra rigtige projekter, inklusive et par fejl, der først viser sig, når man tester mod en rigtig database.
Hvorfor headless?
Den vigtigste gevinst er adskillelsen. Redaktøren arbejder i Strapis admin-panel med indhold, der er struktureret efter domænet: artikler, kategorier, forfattere. Udviklerne har fuld kontrol over design, ydeevne, tilgængelighed og SEO i frontenden og kan udvikle, teste og udgive den uden at røre CMS'et.
Prisen er, at man selv skal bygge det, et traditionelt CMS giver gratis: forhåndsvisning af kladder, cache, der opdateres ved udgivelse, og et sitemap. Det er ikke svært, men det skal med i planen fra starten.
Arkitekturen: ét repository, to images, ét netværk
Frontend og backend ligger i samme git-repository, men bygges som to separate Docker-images. Det giver én historik og én pull request for ændringer, der går på tværs, f.eks. et nyt felt i Strapi og den komponent, der viser det. Samtidig kan de to dele skaleres, genstartes og rulles tilbage hver for sig.
I drift kører fire containere på et isoleret Docker-netværk, space-travel-network: nginx, Next.js, Strapi og PostgreSQL (space-travel-db). Kun nginx har en offentlig port. Next.js henter indhold fra Strapi på det interne netværk, mens browseren henter billeder via /uploads, som nginx sender videre til Strapi. Databasen har slet ingen port udadtil.

Indholdsmodellen: blokke i en Dynamic Zone
En artikel i Strapi har faste felter (titel, slug, resumé, kategori, forfatter, tags) og et felt blocks, som er en Dynamic Zone: en ordnet liste af komponenter, som redaktøren selv sammensætter. Space Travel har ni bloktyper: tekst i Markdown, billede, galleri, kode, callout, citat, video, call-to-action og nøglepunkter.
I frontenden har hver blok et felt __component, f.eks. blog.code. En lille registrering bestemmer, hvilken React-komponent der viser hvilken blok. Ukendte blokke springes over, så en ny bloktype i Strapi ikke vælter siden, før frontenden er klar til den:
// __component -> komponent. Nye bloktyper registreres her.
const REGISTRY = {
"blog.rich-text": RichText,
"blog.image": ImageBlock,
"blog.gallery": Gallery,
"blog.code": CodeBlock,
"blog.callout": Callout,
"blog.quote": Quote,
"blog.video": VideoEmbed,
"blog.cta": CallToAction,
"blog.key-points": KeyPoints,
};
export function BlockRenderer({ blocks, locale }: { blocks: Block[]; locale: Locale }) {
return (
<>
{blocks.map((block, index) => {
const Component = REGISTRY[block.__component];
if (!Component) return null; // ukendte blokke springes over
return <Component key={index} block={block} locale={locale} />;
})}
</>
);
}Den første overraskelse kom fra Strapis API. Relationer, billeder og komponenter i en Dynamic Zone kommer ikke med i svaret, medmindre man beder om dem med populate. Med Strapi 5's on-syntaks angiver man hver bloktype for sig. En integrationstest, der uploader rigtige billeder og sender præcis den forespørgsel, frontenden bruger, viser to ting, som er lette at overse i dokumentationen:
- en bloktype, der ikke står i
on-listen, bliver ikke bare uden indhold. Den forsvinder helt fra svaret; [on][blog.gallery]=truereturnerer blokken, men uden billederne. Billeder og indlejrede komponenter skal nævnes eksplicit.
export const ARTICLE_POPULATE: Readonly<Record<string, string>> = {
"populate[cover]": "true",
"populate[category]": "true",
"populate[tags]": "true",
"populate[author][populate][avatar]": "true",
"populate[seo]": "true",
"populate[blocks][on][blog.rich-text]": "true",
"populate[blocks][on][blog.image][populate][image]": "true",
"populate[blocks][on][blog.gallery][populate][images]": "true",
"populate[blocks][on][blog.code]": "true",
"populate[blocks][on][blog.callout]": "true",
"populate[blocks][on][blog.quote]": "true",
"populate[blocks][on][blog.video]": "true",
"populate[blocks][on][blog.cta]": "true",
"populate[blocks][on][blog.key-points][populate][points]": "true",
};Derfor findes forespørgslen ét sted i frontenden, og testen i backenden bruger den samme liste. Tilføjer man en bloktype ét sted og glemmer det andet, fejler testen.
Cache, udgivelse og forhåndsvisning
Next.js cacher svarene fra Strapi, så en side ikke skal hente alt igen ved hvert besøg. Det rejser det klassiske spørgsmål: hvordan ser læseren en rettelse med det samme? Løsningen er et webhook. Når en redaktør udgiver eller ændrer noget, kalder Strapi en intern route i Next.js med en hemmelig nøgle, og Next.js smider netop den del af cachen ud. Lad Strapi oprette webhooket automatisk ved opstart, hvis det mangler, så det ikke afhænger af et manuelt trin efter hver installation.
Forhåndsvisning af kladder bruger Next.js' draft mode: et link med en hemmelig nøgle sætter en cookie, og så henter siderne kladder fra Strapi med et API-token, der kun kan læse. Her ligger en sikkerhedsfejl, der er nem at overse: Strapi 5 returnerer kladder til alle med læseadgang, der tilføjer ?status=draft til en API-forespørgsel. Upublicerede sider kan altså læses offentligt. Skriv først en test, der viser fejlen, og tilføj så et lille middleware, der fjerner parameteren, medmindre forespørgslen bruger et API-token.
Skema i git, indhold i databasen
En regel har gjort det meste andet nemmere: skemaet ligger i git, indholdet ligger i databasen. Strapis indholdstyper er JSON-filer i repositoryet og bliver en del af backend-imaget. Når Strapi starter, sammenligner den filerne med databasen og opretter de tabeller og kolonner, der mangler. Skemaet ændres derfor kun i udviklingsmiljøet, aldrig i produktion.
Det har to konsekvenser, som hører hjemme direkte i udgivelsesprocessen:
- Tilføjelser er sikre, f.eks. en ny indholdstype eller et nyt valgfrit felt.
- Omdøbninger og sletninger er farlige. Strapi ser en omdøbning som "slet det gamle felt, opret et nyt" og fjerner kolonnen med data. Den slags kræver en migration og en databasebackup lige før udgivelsen.
Udgivelse via eget Docker-registry
Images bygges med eksplicitte versionsnumre, f.eks. registry.space-travel.example/website/frontend:v0.3.0, og skubbes til et privat Docker-registry. Serveren henter de versioner, der står i dens .env-fil. Brug aldrig latest: så er det altid entydigt, hvad der kører, og en tilbagerulning er at sætte den forrige version og starte igen. Har udgivelsen ændret skemaet, skal den backup, der blev taget før udgivelsen, gendannes.
Test der matcher arkitekturen
Opdelingen i frontend og backend afspejles i testene. Unit-tests i begge dele kører uden nogen services, med Strapi, fetch og databasen erstattet af stubs. Integrationstests starter den rigtige Strapi mod en separat PostgreSQL-testdatabase, som aldrig er udviklings- eller produktionsdatabasen. Endelig kører en end-to-end-test hele kæden lokalt: Strapi, den byggede frontend og et webhook, der skal få en ændret titel til at stå på siden inden for få sekunder.
Det vigtigste
- Adskil indhold og præsentation: Strapi leverer struktureret indhold, Next.js ejer design, ydeevne og SEO.
- Byg artikler af blokke i en Dynamic Zone, og lad frontenden vælge komponent ud fra __component.
- Test API-forespørgslerne mod en rigtig Strapi: populate opfører sig ikke altid, som man tror.
- Cache i frontenden kræver et webhook ved udgivelse og en sikker forhåndsvisning af kladder.
- Skema i git, indhold i databasen, og en backup før hver udgivelse, der ændrer skemaet.
Overvejer I en headless løsning?
Vi hjælper gerne med at vurdere, om headless passer til jeres indhold og team, og med arkitekturen, hvis det gør. Tag en uforpligtende snak.
- nextjs
- strapi
- headless
- arkitektur



