Frontend headless pour tout CMS
- 1.Le pattern transversal : l'abstraction par handler d'abord
- 2.Contentful : patterns et anti-patterns
- 3.Storyblok : patterns et anti-patterns
- 4.Hygraph : patterns et anti-patterns
- 5.Sanity : patterns et anti-patterns
- 6.Strapi : patterns et anti-patterns
- 7.DatoCMS : patterns et anti-patterns
- 8.Prismic : patterns et anti-patterns
- 9.Le tableau d'ensemble : ce qui rend l'agnosticisme CMS réel
Laioutr exploite des frontends sur plus de 50 stacks backend différentes. Sept des systèmes de CMS headless les plus utilisés dans le contexte enterprise DACH en font partie. Les patterns qui fonctionnent ne sont pas spécifiques à un CMS - et les anti-patterns qui posent problème se répètent d'un système à l'autre.
Cet article est une documentation directe issue de ces intégrations. Ce n'est pas un "comparatif des 7 meilleurs CMS" - tout le monde sait lire une grille tarifaire. Ce qui est documenté ici : quelles décisions de couche frontend sont les bonnes selon le CMS, et où les équipes reconstruisent systématiquement le même problème.
Le pattern transversal : l'abstraction par handler d'abord
Avant de passer aux patterns propres à chaque CMS, voici le principe transversal qui se vérifie sur l'ensemble des 50+ intégrations :
La couche frontend ne doit pas appeler un SDK de CMS directement. Elle doit appeler un content handler abstrait qui encapsule le SDK du CMS. C'est toute la différence entre un frontend marié en profondeur à un CMS et un frontend agnostique du CMS.
// Anti-pattern : SDK directement dans le code de la page
import { createClient } from 'contentful'
// ou
import { createClient } from '@sanity/client'
// ou
import { GraphQLClient } from 'graphql-request' // hygraph
// Les trois sont des anti-patterns au niveau de la page
// Pattern : interface abstraite au niveau de la plateforme
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>
}Avec cette interface comme fondation, voici les sept systèmes de CMS.
Contentful : patterns et anti-patterns
Ce que la couche frontend absorbe :
La Content Delivery API de Contentful sait parler GraphQL (avec un CDN en frontal), mais elle livre le contenu dans un format propre à Contentful : wrappers `sys`, wrappers `fields`, références d'assets embarquées avec leur propre test `sys.type = 'Asset'`. Cette surcharge de format n'a rien à faire dans le code des composants.
// Handler Contentful : normalise le format Contentful
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', // mapping des locales Contentful !
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)
})
}
}L'anti-pattern Contentful classique :
Rendre le rich text directement dans les composants, sans normaliseur. Le rich text de Contentful est un AST JSON (proche de Slate.js) mais avec des types de nœuds propres à Contentful pour les entrées embarquées. Appeler `documentToReactComponents(richText)` directement dans un composant inscrit une dépendance dure à Contentful dans la couche UI.
Le correctif : un renderer de rich text maison au niveau de la plateforme, qui convertit l'AST Contentful en AST de composants normalisé.
Storyblok : patterns et anti-patterns
Ce que la couche frontend absorbe :
Storyblok travaille avec des blocks imbriqués (components) plutôt qu'avec des content types. C'est conceptuellement différent de Contentful : Storyblok pense en "stories" composées de "blocks". Chaque block porte une clé `component` qui indique quel composant UI rendre.
Le pattern Storyblok qui fonctionne :
// Handler Storyblok
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 fournit directement le nom du composant
data: this.normaliseBlockData(block),
id: block._uid
}))
}
}L'anti-pattern Storyblok classique :
Intégrer la directive `storyblokEditable` directement dans les composants UI finaux. `storyblokEditable(blok)` ajoute des attributs `data-` pour l'overlay du Visual Editor de Storyblok. Cela couple les composants UI à l'infrastructure d'édition de Storyblok. Quand l'éditeur passe sur une autre plateforme (ou qu'il est désactivé), des directives mortes subsistent dans la sortie de rendu.
Le correctif : détecter le mode édition au niveau de la plateforme, pas au niveau des composants. Quand `isEditorMode()` est vrai, les directives sont injectées par le handler - les composants, eux, restent propres.
Hygraph : patterns et anti-patterns
Ce que la couche frontend absorbe :
Hygraph est GraphQL-native. C'est un avantage - mais cela pousse les équipes à écrire des requêtes GraphQL directement dans les composants. Pour une architecture agnostique du CMS, c'est exactement le même anti-pattern que pour n'importe quel autre SDK.
La force de Hygraph, c'est sa couche GraphQL capable de fédération : un seul endpoint GraphQL peut agréger plusieurs sources backend. Pour des frontends à sources de données multiples (CMS + commerce + recherche), c'est un avantage réel - mais seulement si la couche frontend est proprement abstraite du schéma GraphQL.
// Handler Hygraph
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 }
}
}
}
// La requête reste dans le handler, pas dans le composant
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 ... } }
}
}
`L'anti-pattern Hygraph :
Mal configurer le fallback de locale de Hygraph. Hygraph dispose d'un système explicite de fallback de locale : si une entrée n'existe pas dans la locale demandée, elle peut retomber sur une locale de repli. Ne pas le configurer produit des champs vides au lieu du contenu de repli. Cela arrive surtout avec les entrées historiques dont la localisation est incomplète.
Le correctif : renseigner `locales: [$primaryLocale, en]` dans la requête (Hygraph accepte des tableaux de locales comme liste de priorité).
Sanity : patterns et anti-patterns
Ce que la couche frontend absorbe :
Sanity a GROQ - son propre langage de requête, plus puissant que GraphQL mais avec une courbe d'apprentissage plus raide. Pour des développeurs frontend sans expérience de GROQ, la tentation est d'écrire les requêtes GROQ directement dans les server components Next.js. C'est l'équivalent Sanity de l'anti-pattern du SDK Contentful.
// Handler Sanity
class SanityHandler implements CMSHandler {
async fetchPage(slug: string, locale: string): Promise<PageData> {
// GROQ reste dans le handler, pas dans le composant
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)
}
}L'anti-pattern Sanity :
Rendre le Portable Text de Sanity directement dans les composants, sans couche de rendu normalisée. Le Portable Text de Sanity est un format de contenu par blocs, avec des définitions de marks sur mesure pour les liens inline, des annotations personnalisées et des objets embarqués. Utiliser `<PortableText value={portableText} />` directement dans un composant rend ce composant spécifique à Sanity.
Le correctif : un renderer Portable Text maison au niveau de la plateforme, avec des types de blocs normalisés, qui convertit l'entrée propre à Sanity en sortie agnostique du CMS.
Strapi : patterns et anti-patterns
Ce que la couche frontend absorbe :
Strapi propose une API REST (principale) et une API GraphQL (optionnelle, via plugin). Pour des setups enterprise, l'API GraphQL est préférable - mais les installations Strapi varient beaucoup selon la version (v4 ou v5) et la configuration des plugins. La couche handler doit encapsuler cette variance.
Strapi v5 a introduit une nouvelle Document Service API avec `documentId` à la place de `id`. Appeler l'API directement depuis le frontend, sans couche handler, revient à encaisser des breaking changes à chaque upgrade de Strapi.
// Handler Strapi (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 à la place de id
title: page.title,
slug: page.slug,
blocks: this.normaliseStrapiBlocks(page.blocks),
meta: page.seo ?? {}
}
}
}L'anti-pattern Strapi :
Découvrir les erreurs de permissions Strapi à l'exécution. Strapi a un système de permissions granulaire - les endpoints publics doivent être activés explicitement. Ne pas le tester pendant le développement produit des erreurs 403 en production, pour des content types visibles dans l'admin du CMS mais pas accessibles publiquement.
Le correctif : valider les permissions Strapi dans les tests de setup du handler (vérification de l'accessibilité des endpoints à l'initialisation du handler).
DatoCMS : patterns et anti-patterns
Ce que la couche frontend absorbe :
DatoCMS a une API GraphQL particulièrement propre et une bonne bibliothèque cliente TypeScript. Cela encourage un couplage fort - DatoCMS est si agréable à utiliser que l'anti-pattern s'installe subtilement : le handler devient trop mince, parce que l'API semble demander peu de normalisation.
Jusqu'au moment où le modèle de contenu change. DatoCMS permet de modifier le modèle de contenu sans migration de schéma (contrairement à Contentful). C'est une fonctionnalité - mais cela veut dire que le handler doit tester explicitement les changements de modèle, car le client TypeScript n'effectue aucune validation automatique du schéma.
// Handler DatoCMS avec garde de schéma explicite
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)
// Garde de schéma explicite : vérifier les champs critiques
if (!page.title || !page.slug) {
throw new SchemaValidationError(`Page ${slug}: missing required fields`)
}
return this.normaliseDatoCMSPage(page)
}
}L'anti-pattern DatoCMS :
Le mode preview de DatoCMS sans abstraction par handler. DatoCMS a son propre mode preview, qui fonctionne via un `previewSecret` et une route `/api/preview`. L'implémenter directement dans les pages Next.js, sans abstraction par handler, produit une logique de preview spécifique au CMS. Changer de CMS signifie alors réimplémenter la preview de zéro.
Le correctif : abstraire le mode preview au niveau du handler - `handler.getPreviewData(slug)` renvoie les données de preview, indépendamment du mécanisme de preview du CMS.
Prismic : patterns et anti-patterns
Ce que la couche frontend absorbe :
Prismic a son propre concept de slices pour la construction de pages - les pages sont faites de "slices" qui représentent différents types de composants. C'est conceptuellement proche de Storyblok, mais avec son propre workflow SliceMachine pour la génération de code.
// Handler Prismic
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
}
}
}L'anti-pattern Prismic :
Utiliser les composants générés par SliceMachine directement comme composants UI finaux. SliceMachine génère des composants React avec des types de props propres à Prismic. Intégrer ces composants directement dans le design system UI (au lieu de s'en servir comme adaptateurs qui transmettent des props normalisées aux composants du design system) inscrit les types Prismic dans la bibliothèque de composants.
Le correctif : traiter les composants SliceMachine comme une couche d'adaptation - ils traduisent les props Prismic en props du design system, puis appellent des composants génériques du design system.
Le tableau d'ensemble : ce qui rend l'agnosticisme CMS réel
Sur l'ensemble des sept systèmes de CMS, les patterns sont constants :
Ce qui fonctionne dans tous les cas :
- Abstraction par handler au niveau de la plateforme (pas au niveau de la page ni du composant)
- Interface ContentEntry normalisée, qui encapsule les formats propres à chaque CMS
- Mapping des locales dans le handler (chaque CMS a ses propres conventions de locale)
- Mode preview comme responsabilité du handler, pas de la page
- Gardes de schéma sur les champs critiques (protection contre la dérive du modèle)
Ce qui pose problème dans tous les cas :
- Appels de SDK directement dans les composants ou dans les pages
- Rendu du rich text sans renderer normalisé
- Logique de preview spécifique au CMS dans la couche UI
- Chaînes de locale sans logique de mapping (chaque CMS a des codes de locale différents)
- Directives de mode édition dans les composants UI finaux
Les 50+ intégrations de stacks sur la plateforme Composable Headless Frontend reposent sur ce pattern. Le CMS est une décision d'implémentation interchangeable - pas une décision d'architecture.
[Marcel section]
C'est le cœur de ce que nous entendons chez Laioutr par "headless cms agnostic". Pas : "nous prenons en charge beaucoup de systèmes de CMS." Mais : "la couche frontend est construite de telle sorte que le CMS soit un composant configurable, pas une dépendance structurelle."
Si vous voulez évaluer l'effort d'ingénierie concret pour votre stack - lequel des sept patterns de handler concerne votre setup, à quoi ressemble le travail d'abstraction sur du code frontend existant - une conversation technique avec Sebastian est le chemin le plus direct.
Le concept Agentic Frontend Management Platform constitue le cadre ; l'implémentation du handler, c'est le travail concret. Nous pouvons parcourir les deux avec vous.
[End Marcel section]
Plus sur cette capacité : Content Management
À lire aussi : Pas de changement de CMS, pas de réécriture du storefront · Quand votre couche CMS change de mains · CMS headless pour l'eCommerce Next.js 2026