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:
- Handler abstraction at platform level (not at page or component level)
- Normalised ContentEntry interface encapsulating CMS-specific formats
- Locale mapping in the handler (every CMS has its own locale conventions)
- Preview mode as a handler responsibility, not a page responsibility
- Schema guards for critical fields (protects against model drift)
What causes problems in every case:
- SDK calls directly in components or pages
- Rich text rendering without a normalised renderer
- CMS-specific preview logic in the UI layer
- Locale strings without mapping logic (every CMS has different locale codes)
- 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]
Related: No CMS swap, no storefront rewrite · When your CMS layer changes hands · Headless CMS for Next.js eCommerce 2026