Hero slot3 de

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:

  1. Handler-Abstraktion auf Platform-Ebene (nicht auf Page- oder Component-Ebene)
  2. Normalisiertes ContentEntry-Interface, das CMS-spezifische Formate kapselt
  3. Locale-Mapping im Handler (jedes CMS hat eigene Locale-Konventionen)
  4. Preview-Mode als Handler-Responsibility, nicht als Page-Responsibility
  5. Schema-Guards für kritische Felder (schützt vor Model-Drift)

Was in allen Fällen zu Problemen führt:

  1. SDK-Aufrufe direkt in Components oder Pages
  2. Rich-Text-Rendering ohne normalisierten Renderer
  3. CMS-spezifische Preview-Logik in der UI-Schicht
  4. Locale-Strings ohne Mapping-Logik (jedes CMS hat andere Locale-Codes)
  5. 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

Mehr interessante Artikel

Praxiswissen für Frontend-Entwicklung, smarte Agenten und Headless

Book a demo mobile
Strategie-Gespräch

Bereit, Dein Frontend zur Steuerebene zu machen?

Zeig uns Deinen Stack, Deine Roadmap, Dein Replatforming-Szenario, wir zeigen Dir, wie Laioutr passt, was es kostet und wie schnell ihr live geht.

"Nach 30 Minuten wussten wir, dass Laioutr unser Replatforming machbar macht." - Daniel B., CEO, hygibox.de