Headless Frontend, das jedes CMS verträgt, Pattern und Anti-Pattern aus 50+ Stack-Integrationen
Laioutr betreibt Frontends für mehr als 50 unterschiedliche Backend-Stacks. Darin enthalten sind sieben der am häufigsten verwendeten Headless-CMS-Systeme im DACH-Enterprise-Kontext. Die Pattern, die funktionieren, sind nicht CMS-spezifisch - und die Anti-Pattern, die zu Problemen führen, wiederholen sich systemübergreifend.
Dieser Post ist eine direkte Dokumentation aus diesen Integrationen. Kein "Top 7 CMS Comparison" - jeder kann Pricing-Tabellen lesen. Was hier dokumentiert wird: welche Frontend-Layer-Entscheidungen pro CMS die richtigen sind, und wo Teams immer wieder das gleiche Problem bauen.
Das übergreifende Pattern: Handler-Abstraktion zuerst
Bevor wir die CMS-spezifischen Pattern durchgehen, das übergreifende Prinzip, das in allen 50+ Integrationen gilt:
Der Frontend-Layer sollte kein CMS-SDK direkt aufrufen. Er sollte einen abstrakten Content-Handler aufrufen, der das CMS-SDK kapselt. Das ist der Unterschied zwischen einem Frontend, das tief mit einem CMS verheiratet ist, und einem Frontend, das CMS-agnostisch ist.
// Anti-Pattern: SDK direkt im Page-Code
import { createClient } from 'contentful'
// oder
import { createClient } from '@sanity/client'
// oder
import { GraphQLClient } from 'graphql-request' // hygraph
// Alle drei sind Anti-Pattern auf Page-Ebene
// Pattern: Abstraktes Interface auf Platform-Ebene
interface CMSHandler {
fetchPage(slug: string, locale: string): Promise<PageData>
fetchCollection(type: string, locale: string, params?: QueryParams): Promise<CollectionData>
fetchAsset(id: string): Promise<AssetData>
getPreviewData(slug: string): Promise<PreviewData | null>
}Mit diesem Interface als Fundament gehen wir jetzt durch die sieben CMS-Systeme.
Contentful: Pattern und Anti-Pattern
Was der Frontend-Layer abfedert:
Contentfuls Content-Delivery-API ist GraphQL-fähig (CDN-backed), aber sie liefert Content in einem Contentful-spezifischen Format: `sys`-Wrapper, `fields`-Wrapper, eingebettete Asset-References mit eigenem `sys.type = 'Asset'`-Check. Dieser Format-Overhead gehört nicht in den Component-Code.
// Contentful-Handler: normalisiert das Contentful-Format
class ContentfulHandler implements CMSHandler {
async fetchPage(slug: string, locale: string): Promise<PageData> {
const response = await this.client.getEntries({
content_type: 'page',
'fields.slug': slug,
locale: locale === 'de' ? 'de' : 'en-US', // Contentful-Locale-Mapping!
include: 3 // Tiefe der Referenz-Auflösung
})
if (!response.items.length) throw new ContentNotFoundError(slug)
const entry = response.items[0]
return {
id: entry.sys.id,
title: entry.fields.title as string,
slug: entry.fields.slug as string,
blocks: this.normalizeBlocks(entry.fields.blocks),
meta: this.normalizeMeta(entry.fields.seo)
}
}
private normalizeBlocks(rawBlocks: unknown[]): Block[] {
// Contentful-spezifisch: Linked-Entry-Resolution, Asset-Reference-Check
return rawBlocks.map(block => {
const b = block as ContentfulEntry
if (b.sys?.type === 'Asset') return this.normalizeAsset(b)
return this.normalizeEntry(b)
})
}
}Das klassische Contentful-Anti-Pattern:
Rich-Text direkt in Components rendern ohne Normalisierer. Contentfuls Rich-Text ist ein JSON-AST (ähnlich Slate.js), aber mit Contentful-spezifischen Node-Types für eingebettete Einträge. Wer `documentToReactComponents(richText)` direkt in der Component aufruft, baut eine harte Contentful-Abhängigkeit in die UI-Schicht.
Der Fix: Ein eigener Rich-Text-Renderer auf Platform-Ebene, der Contentful-AST → normalisiertes Component-AST konvertiert.
Storyblok: Pattern und Anti-Pattern
Was der Frontend-Layer abfedert:
Storyblok arbeitet mit verschachtelten Blöcken (Components) statt Content-Types. Das ist konzeptuell anders als Contentful: Storyblok denkt in "Storys", die aus "Blöcken" bestehen. Jeder Block hat einen `component`-Schlüssel, der angibt, welche UI-Component gerendert werden soll.
Das Storyblok-Pattern, das funktioniert:
// Storyblok-Handler
class StoryblokHandler implements CMSHandler {
async fetchPage(slug: string, locale: string): Promise<PageData> {
const { data } = await this.storyblokApi.get(`cdn/stories/${slug}`, {
version: 'published',
language: locale,
resolve_relations: 'global_reference.reference'
})
return {
id: data.story.uuid,
title: data.story.content.title,
slug: data.story.slug,
blocks: this.mapStoryblokBlocks(data.story.content.body),
meta: data.story.content.seo_metadata
}
}
private mapStoryblokBlocks(blocks: StoryblokBlock[]): Block[] {
return blocks.map(block => ({
type: block.component, // Storyblok gibt den Component-Namen direkt
data: this.normalizeBlockData(block),
id: block._uid
}))
}
}Das klassische Storyblok-Anti-Pattern:
Den `storyblokEditable`-Directive direkt in finale UI-Components einbauen. `storyblokEditable(blok)` fügt `data-`-Attribute für das Storyblok-Visual-Editor-Overlay hinzu. Das koppelt UI-Components an Storybloks Editor-Infrastruktur. Wenn der Editor auf eine andere Plattform wechselt (oder ausgeschaltet wird), verbleiben tote Direktiven im Render-Output.
Der Fix: Editor-Mode-Erkennung auf Platform-Ebene, nicht auf Component-Ebene. Wenn `isEditorMode()`, dann werden die Direktiven vom Handler injiziert - Components selbst bleiben sauber.
Hygraph: Pattern und Anti-Pattern
Was der Frontend-Layer abfedert:
Hygraph ist GraphQL-native. Das ist ein Vorteil - aber es verleitet dazu, GraphQL-Queries direkt in Components zu schreiben. Für eine CMS-agnostische Architektur ist das das gleiche Anti-Pattern wie bei jedem anderen SDK.
Hygraphs Stärke ist die federation-fähige GraphQL-Schicht: ein einzelner GraphQL-Endpoint kann mehrere Backend-Quellen zusammenführen. Für Frontends, die mehrere Datenquellen haben (CMS + Commerce + Search), ist das ein echter Vorteil - aber nur, wenn der Frontend-Layer sauber vom GraphQL-Schema abstrahiert ist.
// Hygraph-Handler
class HygraphHandler implements CMSHandler {
async fetchPage(slug: string, locale: string): Promise<PageData> {
const { page } = await this.graphqlClient.request<HygraphPageResponse>(
HYGRAPH_PAGE_QUERY,
{ slug, locale: locale === 'de' ? 'de' : 'en' }
)
if (!page) throw new ContentNotFoundError(slug)
return {
id: page.id,
title: page.title,
slug: page.slug,
blocks: page.blocks.map(b => this.normalizeHygraphBlock(b)),
meta: { title: page.seoTitle, description: page.seoDescription }
}
}
}
// Query ist im Handler, nicht in der Component
const HYGRAPH_PAGE_QUERY = gql`
query GetPage($slug: String!, $locale: Locale!) {
page(where: { slug: $slug }, locales: [$locale, en]) {
id title slug seoTitle seoDescription
blocks { ... on HeroBlock { __typename headline ... } }
}
}
`Das Hygraph-Anti-Pattern:
Hygraph-Locale-Fallback falsch konfigurieren. Hygraph hat ein explizites Locale-Fallback-System: wenn ein Eintrag in der angefragten Locale nicht existiert, kann auf eine Fallback-Locale zurückgefallen werden. Wer das nicht konfiguriert, bekommt leere Felder statt Fallback-Content. Das passiert am häufigsten bei Legacy-Einträgen ohne vollständige Lokalisierung.
Der Fix: In der Query `locales: [$primaryLocale, en]` setzen (Hygraph unterstützt Locale-Arrays als Prioritätsliste).
Sanity: Pattern und Anti-Pattern
Was der Frontend-Layer abfedert:
Sanity hat GROQ - eine eigene Query-Language, die mächtiger als GraphQL ist, aber eine steilere Lernkurve hat. Für Frontend-Entwickler ohne GROQ-Erfahrung besteht die Versuchung, GROQ-Queries direkt in Next.js-Server-Components zu schreiben. Das ist das Sanity-Äquivalent des Contentful-SDK-Anti-Patterns.
// Sanity-Handler
class SanityHandler implements CMSHandler {
async fetchPage(slug: string, locale: string): Promise<PageData> {
// GROQ bleibt im Handler, nicht in der Component
const query = groq`
*[_type == "page" && slug.current == $slug && language == $locale][0] {
_id,
title,
"slug": slug.current,
blocks[] {
_type,
_key,
...
},
seo { title, description }
}
`
const page = await this.sanityClient.fetch<SanityPage>(query, { slug, locale })
if (!page) throw new ContentNotFoundError(slug)
return this.normalizeSanityPage(page)
}
}Das Sanity-Anti-Pattern:
Sanity-Portable-Text direkt in Components rendern ohne normalisierten Render-Layer. Sanity-Portable-Text ist ein Block-Content-Format mit custom-Mark-Definitions für Inline-Links, Custom-Annotations, Embedded-Objects. `<PortableText value={portableText} />` direkt in einer Component macht die Component Sanity-spezifisch.
Der Fix: Eigener Portable-Text-Renderer auf Platform-Ebene mit normalisierten Block-Types, der den Sanity-spezifischen Input in CMS-agnostischen Output konvertiert.
Strapi: Pattern und Anti-Pattern
Was der Frontend-Layer abfedert:
Strapi hat eine REST-API (primär) und eine GraphQL-API (optional, via Plugin). Für Enterprise-Setups ist die GraphQL-API vorzuziehen - aber Strapi-Setups variieren erheblich je nach Version (v4 vs. v5) und Plugin-Konfiguration. Der Handler-Layer muss diese Varianz kapseln.
Strapi v5 hat eine neue Document-Service-API mit `documentId` statt `id`. Wer die API ohne Handler-Layer direkt im Frontend aufruft, bekommt Breaking-Changes beim Strapi-Upgrade.
// Strapi-Handler (v5)
class StrapiHandler implements CMSHandler {
async fetchPage(slug: string, locale: string): Promise<PageData> {
const response = await fetch(
`${this.baseUrl}/api/pages?filters[slug][$eq]=${slug}&locale=${locale}&populate=deep`,
{ headers: { Authorization: `Bearer ${this.token}` } }
)
const { data } = await response.json() as StrapiV5Response
if (!data?.length) throw new ContentNotFoundError(slug)
const [page] = data
return {
id: page.documentId, // v5: documentId statt id
title: page.title,
slug: page.slug,
blocks: this.normalizeStrapiBlocks(page.blocks),
meta: page.seo ?? {}
}
}
}Das Strapi-Anti-Pattern:
Strapi-Permission-Fehler zur Runtime entdecken. Strapi hat ein granulares Permission-System - Public-Endpoints müssen explizit freigegeben werden. Wer das im Entwicklung-Prozess nicht testet, bekommt 403-Fehler in Produktion für Content-Types, die im CMS-Admin sichtbar sind, aber nicht öffentlich zugänglich.
Der Fix: Strapi-Permissions als Teil des Handler-Setup-Tests validieren (Endpoint-Accessibility-Check bei Handler-Initialisierung).
DatoCMS: Pattern und Anti-Pattern
Was der Frontend-Layer abfedert:
DatoCMS hat eine besonders saubere GraphQL-API und eine gute TypeScript-Client-Library. Das verleitet zu enger Kopplung - DatoCMS ist so angenehm zu nutzen, dass das Anti-Pattern subtil entsteht: der Handler wird zu dünn, weil die API kaum Normalisierung zu brauchen scheint.
Bis das Content-Model sich ändert. DatoCMS erlaubt Content-Model-Änderungen ohne Schema-Migration (im Gegensatz zu Contentful). Das ist ein Feature - aber es bedeutet, dass der Handler explizit auf Model-Änderungen testen muss, weil der TypeScript-Client keine automatische Schema-Validation hat.
// DatoCMS-Handler mit explizitem Schema-Guard
class DatoCMSHandler implements CMSHandler {
async fetchPage(slug: string, locale: string): Promise<PageData> {
const { page } = await this.client.request<DatoCMSPageResponse>(
PAGE_QUERY,
{ slug, locale: locale as SiteLocale }
)
if (!page) throw new ContentNotFoundError(slug)
// Expliziter Schema-Guard: kritische Felder prüfen
if (!page.title || !page.slug) {
throw new SchemaValidationError(`Page ${slug}: missing required fields`)
}
return this.normalizeDatoCMSPage(page)
}
}Das DatoCMS-Anti-Pattern:
DatoCMS-Preview-Mode ohne Handler-Abstraktion. DatoCMS hat einen eigenen Preview-Mode, der über `previewSecret` und `/api/preview`-Route funktioniert. Wer das direkt in Next.js-Pages implementiert, ohne Handler-Abstraktion, hat Preview-Logik, die CMS-spezifisch ist. Beim CMS-Wechsel muss Preview neu implementiert werden.
Der Fix: Preview-Mode auf Handler-Ebene abstrahieren - `handler.getPreviewData(slug)` gibt Preview-Daten zurück, unabhängig vom CMS-Preview-Mechanismus.
Prismic: Pattern und Anti-Pattern
Was der Frontend-Layer abfedert:
Prismic hat ein eigenes Slice-Konzept für page-building - Pages bestehen aus "Slices", die unterschiedliche Component-Types repräsentieren. Das ist konzeptuell ähnlich zu Storyblok, aber mit einem eigenen SliceMachine-Workflow für Code-Generation.
// Prismic-Handler
class PrismicHandler implements CMSHandler {
async fetchPage(slug: string, locale: string): Promise<PageData> {
const document = await this.client.getByUID('page', slug, {
lang: locale === 'de' ? 'de-de' : 'en-us'
})
if (!document) throw new ContentNotFoundError(slug)
return {
id: document.id,
title: document.data.title[0]?.text ?? '',
slug: document.uid,
blocks: document.data.slices.map(slice => this.normalizePrismicSlice(slice)),
meta: {
title: document.data.meta_title ?? '',
description: document.data.meta_description ?? ''
}
}
}
private normalizePrismicSlice(slice: PrismicSlice): Block {
return {
type: slice.slice_type, // Prismic gibt den Slice-Typ direkt
data: {
primary: slice.primary,
items: slice.items
},
id: slice.id
}
}
}Das Prismic-Anti-Pattern:
SliceMachine-generierte Components direkt als finale UI-Components verwenden. SliceMachine generiert React-Components, die Prismic-spezifische Props-Typen haben. Wer diese Components direkt in das UI-Design-System integriert (statt sie als Adapters zu nutzen, die normalisierte Props in Design-System-Components übergeben), baut Prismic-Typen in die Component-Library ein.
Der Fix: SliceMachine-Components als Adapter-Layer behandeln - sie übersetzen Prismic-Props in Design-System-Props, rufen dann generische Design-System-Components auf.
Das gemeinsame Bild: was CMS-Agnostizität real macht
Über alle sieben CMS-Systeme hinweg sind die Muster konsistent:
Was in allen Fällen funktioniert:
- Handler-Abstraktion auf Platform-Ebene (nicht auf Page- oder Component-Ebene)
- Normalisiertes ContentEntry-Interface, das CMS-spezifische Formate kapselt
- Locale-Mapping im Handler (jedes CMS hat eigene Locale-Konventionen)
- Preview-Mode als Handler-Responsibility, nicht als Page-Responsibility
- Schema-Guards für kritische Felder (schützt vor Model-Drift)
Was in allen Fällen zu Problemen führt:
- SDK-Aufrufe direkt in Components oder Pages
- Rich-Text-Rendering ohne normalisierten Renderer
- CMS-spezifische Preview-Logik in der UI-Schicht
- Locale-Strings ohne Mapping-Logik (jedes CMS hat andere Locale-Codes)
- Editor-Mode-Direktiven in finalen UI-Components
Die 50+ Stack-Integrationen auf der Composable Headless Frontend-Plattform basieren auf diesem Muster. Das CMS ist eine austauschbare Implementierungs-Entscheidung - nicht eine Architektur-Entscheidung.
[Marcel-Abschnitt]
Das ist der Kern von dem, was wir bei Laioutr als "headless cms agnostisch" verstehen. Nicht: "wir unterstützen viele CMS." Sondern: "der Frontend-Layer ist so gebaut, dass das CMS eine konfigurierbare Komponente ist, keine strukturelle Abhängigkeit."
Wenn du den konkreten Engineering-Aufwand für deinen Stack einschätzen willst - welche der sieben Handler-Patterns für euer Setup relevant ist, wie hoch der Abstraktions-Aufwand für einen bestehenden Frontend-Code ist - dann ist ein technisches Gespräch mit Sebastian der direkteste Weg dorthin.
Das Agentic Frontend Management Platform-Konzept ist der Rahmen; die Handler-Implementierung ist die konkrete Arbeit. Beides können wir mit dir durchgehen.
[Ende Marcel-Abschnitt]
Verwandte Themen: Kein CMS-Tausch, kein Storefront-Rewrite · Wenn der CMS-Layer den Besitzer wechselt · Headless CMS für Next.js eCommerce 2026