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

App Shopify
Shopify
Shopify is a commerce platform for selling online and in physical retail.
App shopware
Shopware
Shopware is a flexible ecommerce platform from Europe for product catalogs and omnichannel commerce.
App adobe commerce
Adobe Commerce
Adobe Commerce is an enterprise commerce platform for complex, global B2C and B2B scenarios.
Planned
App B2B sellers suite
B2Bsellers
B2B suite for Shopware that turns an online store into a professional B2B commerce platform.
Planned
App commerce layer
Commerce Layer
Commerce Layer is a headless commerce platform for making inventory and catalogs available online.
App commercetools
Commercetools
Commercetools is a SaaS-based headless ecommerce platform used worldwide.
App emporix
Emporix
Emporix is a composable, API-first commerce platform for scalable B2B and B2C scenarios.
Planned
App HCL Software
HCL Software
Enterprise suite for digital commerce and experience with extensive configurability.
Planned
App intershop
Intershop
Enterprise commerce platform for complex B2B and B2C business models.
Planned
App magento 2
Magento 2
Widely used, extensible commerce platform for B2C and B2B scenarios.
App Oxid
OXID eShop
OXID eShop is an extensible commerce platform for complex B2B and B2C requirements.
Planned
App cover patchworks
Patchworks
Patchworks is a low-code iPaaS that connects ecommerce, ERP, WMS, 3PL, and marketplaces.
Planned
App PRESTASHOP
Prestashop
Open-source commerce platform for small and midsize merchants in Europe and beyond.
Planned
App saleor
Saleor
Open-source, API-first commerce platform built on GraphQL for custom storefronts.
Planned
App Commercecloud
Salesforce Commerce Cloud
Salesforce Commerce Cloud is a cloud-based enterprise commerce platform for businesses of any size.
Planned
App SAP
SAP Commerce Cloud
Enterprise commerce platform for complex catalogs, pricing models, and omnichannel journeys.
Planned
App SCAYLE
Scayle
SCAYLE is a commerce engine that helps brands and retailers scale their business.
Planned
App spryker
Spryker
Composable commerce platform for sophisticated B2B and B2C business models.
App Sylius
Sylius
Sylius is a developer-friendly ecommerce framework for B2C and B2B shopping experiences.
Planned
App vendure
Vendure
Vendure is a headless commerce platform for businesses with complex requirements.
Coming Soon
App VTEX
VTEX
Cloud-native, composable commerce platform for B2B and B2C at scale.
Planned
App Websale
Websale
Stable, enterprise-ready commerce backend for complex retail environments.
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