Hero slot3 en

Headless frontend that survives any CMS, patterns from 50+ stack integrations

Laioutr runs frontends across more than 50 different backend stacks. Seven of the most commonly used headless CMS systems in the DACH enterprise context are part of that. The patterns that work are not CMS-specific - and the anti-patterns that cause problems repeat themselves across systems.

This post is direct documentation from those integrations. Not a "Top 7 CMS Comparison" - anyone can read pricing tables. What is documented here: which frontend layer decisions are correct per CMS, and where teams consistently build the same problem.

The overarching pattern: handler abstraction first

Before walking through the CMS-specific patterns, the overarching principle that holds across all 50+ integrations:

The frontend layer should not call a CMS SDK directly. It should call an abstract content handler that encapsulates the CMS SDK. That is the difference between a frontend that is deeply married to a CMS and one that is CMS-agnostic.

// Anti-pattern: SDK directly in page code
import { createClient } from 'contentful'
// or
import { createClient } from '@sanity/client'
// or
import { GraphQLClient } from 'graphql-request' // hygraph

// All three are anti-patterns at page level

// Pattern: abstract interface at platform level
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>
}

With this interface as foundation, here are the seven CMS systems.

Contentful: patterns and anti-patterns

What the frontend layer absorbs:

Contentful's Content Delivery API is GraphQL-capable (CDN-backed), but it delivers content in a Contentful-specific format: `sys` wrappers, `fields` wrappers, embedded asset references with their own `sys.type = 'Asset'` check. This format overhead does not belong in component code.

// Contentful handler: normalises the 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
    })

    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.normaliseBlocks(entry.fields.blocks),
      meta: this.normaliseMeta(entry.fields.seo)
    }
  }

  private normaliseBlocks(rawBlocks: unknown[]): Block[] {
    return rawBlocks.map(block => {
      const b = block as ContentfulEntry
      if (b.sys?.type === 'Asset') return this.normaliseAsset(b)
      return this.normaliseEntry(b)
    })
  }
}

The classic Contentful anti-pattern:

Rendering rich text directly in components without a normaliser. Contentful's rich text is a JSON AST (similar to Slate.js) but with Contentful-specific node types for embedded entries. Calling `documentToReactComponents(richText)` directly in a component builds a hard Contentful dependency into the UI layer.

The fix: a custom rich text renderer at platform level that converts Contentful AST to normalised component AST.

Storyblok: patterns and anti-patterns

What the frontend layer absorbs:

Storyblok works with nested blocks (components) rather than content types. That is conceptually different from Contentful: Storyblok thinks in "stories" composed of "blocks". Each block has a `component` key indicating which UI component to render.

The Storyblok pattern that works:

// 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 gives the component name directly
      data: this.normaliseBlockData(block),
      id: block._uid
    }))
  }
}

The classic Storyblok anti-pattern:

Building the `storyblokEditable` directive directly into final UI components. `storyblokEditable(blok)` adds `data-` attributes for the Storyblok Visual Editor overlay. This couples UI components to Storyblok's editor infrastructure. When the editor moves to another platform (or is disabled), dead directives remain in the render output.

The fix: editor mode detection at platform level, not component level. When `isEditorMode()`, the directives are injected by the handler - components themselves stay clean.

Hygraph: patterns and anti-patterns

What the frontend layer absorbs:

Hygraph is GraphQL-native. That is an advantage - but it tempts teams to write GraphQL queries directly in components. For a CMS-agnostic architecture, this is the same anti-pattern as any other SDK.

Hygraph's strength is its federation-capable GraphQL layer: a single GraphQL endpoint can aggregate multiple backend sources. For frontends with multiple data sources (CMS + commerce + search), that is a real advantage - but only when the frontend layer is cleanly abstracted from the GraphQL schema.

// 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.normaliseHygraphBlock(b)),
      meta: { title: page.seoTitle, description: page.seoDescription }
    }
  }
}

// Query stays in the handler, not in the 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 ... } }
    }
  }
`

The Hygraph anti-pattern:

Incorrectly configuring Hygraph locale fallback. Hygraph has an explicit locale fallback system: if an entry does not exist in the requested locale, it can fall back to a fallback locale. Not configuring this results in empty fields instead of fallback content. This happens most often with legacy entries lacking full localisation.

The fix: set `locales: [$primaryLocale, en]` in the query (Hygraph supports locale arrays as a priority list).

Sanity: patterns and anti-patterns

What the frontend layer absorbs:

Sanity has GROQ - its own query language, more powerful than GraphQL but with a steeper learning curve. For frontend developers without GROQ experience, the temptation is to write GROQ queries directly in Next.js server components. That is the Sanity equivalent of the Contentful SDK anti-pattern.

// Sanity handler
class SanityHandler implements CMSHandler {
  async fetchPage(slug: string, locale: string): Promise<PageData> {
    // GROQ stays in the handler, not in the 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.normaliseSanityPage(page)
  }
}

The Sanity anti-pattern:

Rendering Sanity Portable Text directly in components without a normalised render layer. Sanity Portable Text is a block content format with custom mark definitions for inline links, custom annotations, and embedded objects. Using `<PortableText value={portableText} />` directly in a component makes the component Sanity-specific.

The fix: a custom Portable Text renderer at platform level with normalised block types, converting Sanity-specific input into CMS-agnostic output.

Strapi: patterns and anti-patterns

What the frontend layer absorbs:

Strapi has a REST API (primary) and a GraphQL API (optional, via plugin). For enterprise setups, the GraphQL API is preferable - but Strapi setups vary considerably by version (v4 vs. v5) and plugin configuration. The handler layer must encapsulate this variance.

Strapi v5 introduced a new Document Service API with `documentId` instead of `id`. Calling the API directly in the frontend without a handler layer means breaking changes on every 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 instead of id
      title: page.title,
      slug: page.slug,
      blocks: this.normaliseStrapiBlocks(page.blocks),
      meta: page.seo ?? {}
    }
  }
}

The Strapi anti-pattern:

Discovering Strapi permission errors at runtime. Strapi has a granular permission system - public endpoints must be explicitly enabled. Not testing this during development results in 403 errors in production for content types that are visible in the CMS admin but not publicly accessible.

The fix: validate Strapi permissions as part of handler setup tests (endpoint accessibility check during handler initialisation).

DatoCMS: patterns and anti-patterns

What the frontend layer absorbs:

DatoCMS has a particularly clean GraphQL API and a good TypeScript client library. That encourages tight coupling - DatoCMS is so pleasant to work with that the anti-pattern emerges subtly: the handler becomes too thin because the API appears to need little normalisation.

Until the content model changes. DatoCMS allows content model changes without schema migrations (unlike Contentful). That is a feature - but it means the handler must explicitly test for model changes, because the TypeScript client has no automatic schema validation.

// DatoCMS handler with explicit 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)

    // Explicit schema guard: check critical fields
    if (!page.title || !page.slug) {
      throw new SchemaValidationError(`Page ${slug}: missing required fields`)
    }

    return this.normaliseDatoCMSPage(page)
  }
}

The DatoCMS anti-pattern:

DatoCMS preview mode without handler abstraction. DatoCMS has its own preview mode that works via `previewSecret` and an `/api/preview` route. Implementing this directly in Next.js pages without handler abstraction results in preview logic that is CMS-specific. Switching CMS means reimplementing preview from scratch.

The fix: abstract preview mode at handler level - `handler.getPreviewData(slug)` returns preview data, independent of the CMS preview mechanism.

Prismic: patterns and anti-patterns

What the frontend layer absorbs:

Prismic has its own slice concept for page building - pages consist of "slices" representing different component types. That is conceptually similar to Storyblok, but with its own SliceMachine workflow for 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.normalisePrismicSlice(slice)),
      meta: {
        title: document.data.meta_title ?? '',
        description: document.data.meta_description ?? ''
      }
    }
  }

  private normalisePrismicSlice(slice: PrismicSlice): Block {
    return {
      type: slice.slice_type,
      data: {
        primary: slice.primary,
        items: slice.items
      },
      id: slice.id
    }
  }
}

The Prismic anti-pattern:

Using SliceMachine-generated components directly as final UI components. SliceMachine generates React components with Prismic-specific prop types. Integrating these components directly into the UI design system (rather than using them as adapters that pass normalised props to design system components) builds Prismic types into the component library.

The fix: treat SliceMachine components as an adapter layer - they translate Prismic props into design system props, then call generic design system components.

The common picture: what makes CMS agnosticism real

Across all seven CMS systems the patterns are consistent:

What works in every case:

  1. Handler abstraction at platform level (not at page or component level)
  2. Normalised ContentEntry interface encapsulating CMS-specific formats
  3. Locale mapping in the handler (every CMS has its own locale conventions)
  4. Preview mode as a handler responsibility, not a page responsibility
  5. Schema guards for critical fields (protects against model drift)

What causes problems in every case:

  1. SDK calls directly in components or pages
  2. Rich text rendering without a normalised renderer
  3. CMS-specific preview logic in the UI layer
  4. Locale strings without mapping logic (every CMS has different locale codes)
  5. Editor mode directives in final UI components

The 50+ stack integrations on the Composable Headless Frontend platform are built on this pattern. The CMS is an interchangeable implementation decision - not an architecture decision.

[Marcel section]

This is the core of what we mean at Laioutr by "headless cms agnostic". Not: "we support many CMS systems." But: "the frontend layer is built so that the CMS is a configurable component, not a structural dependency."

If you want to assess the concrete engineering effort for your stack - which of the seven handler patterns is relevant for your setup, what the abstraction effort looks like for existing frontend code - a technical conversation with Sebastian is the most direct path to that.

The Agentic Frontend Management Platform concept is the framework; the handler implementation is the concrete work. We can walk through both with you.

[End Marcel section]

More on this capability: Content Management

Related: No CMS swap, no storefront rewrite · When your CMS layer changes hands · Headless CMS for Next.js eCommerce 2026

More interesting articles

Practical know-how for frontend development, smart agents, and headless

Shopify
Shopify ist eine Commerce-Plattform zum Verkaufen online und im stationären Handel.
Shopware
Shopware ist eine flexible E-Commerce-Plattform aus Europa für Produktkataloge und Omnichannel-Commerce.
Planned
Scayle
SCAYLE ist eine Commerce-Engine, mit der Marken und Händler ihr Geschäft skalieren.
Planned
Commerce Layer
Commerce Layer ist eine Headless-Commerce-Plattform, um Bestände und Kataloge online verfügbar zu machen.
Planned
Salesforce Commerce Cloud
Salesforce Commerce Cloud ist eine cloudbasierte Enterprise-Commerce-Plattform für Unternehmen jeder Größe.
Commercetools
Commercetools ist eine SaaS-basierte, headless E-Commerce-Plattform mit weltweitem Einsatz.
Sylius
Sylius ist ein entwicklerfreundliches E-Commerce-Framework für B2C- und B2B-Shopping-Erlebnisse.
OXID eShop
OXID eShop ist eine erweiterbare Commerce-Plattform für komplexe B2B- und B2C-Anforderungen.
Emporix
Emporix ist eine composable, API-first Commerce-Plattform für skalierbare B2B- und B2C-Szenarien.
Adobe Commerce
Adobe Commerce ist eine Enterprise-Commerce-Plattform für komplexe, globale B2C- und B2B-Szenarien.
Coming Soon
VTEX
Cloud-native, composable Commerce-Plattform für B2B und B2C im großen Maßstab.
Planned
Spryker
Composable Commerce-Plattform für anspruchsvolle B2B- und B2C-Geschäftsmodelle.
Planned
SAP Commerce Cloud
Enterprise-Commerce-Plattform für komplexe Kataloge, Preismodelle und Omnichannel-Journeys.
Planned
Websale
Stabiles, enterprise-taugliches Commerce-Backend für komplexe Handelsumgebungen.
Planned
Intershop
Enterprise-Commerce-Plattform für komplexe B2B- und B2C-Geschäftsmodelle.
Planned
Magento 2
Weit verbreitete, erweiterbare Commerce-Plattform für B2C- und B2B-Szenarien.
Planned
B2Bsellers
B2B-Suite für Shopware, die den Online-Shop zur professionellen B2B-Commerce-Plattform macht.
Planned
Saleor
Open-Source-, API-first-Commerce-Plattform auf GraphQL-Basis für Custom-Storefronts.
Planned
Prestashop
Open-Source-Commerce-Plattform für kleine und mittlere Händler in Europa und darüber hinaus.
Planned
Vendure
Vendure ist eine Headless-Commerce-Plattform für Unternehmen mit komplexen Anforderungen.
Planned
Patchworks
Patchworks ist eine Low-Code-iPaaS, die E-Commerce, ERP, WMS, 3PL und Marktplätze verbindet.
Planned
HCL Software
Enterprise-Suite für digitalen Commerce und Experience mit hoher Konfigurierbarkeit.
Book a demo mobile
Strategy call

Ready to turn your frontend into a control layer?

Show us your stack, your roadmap, your replatforming scenario, and we'll show you how Laioutr fits, what it costs, and how fast you go live.

"After 30 minutes, we knew Laioutr makes our replatforming feasible." - Daniel B., CEO, hygibox.de