Frontend headless que sobrevive a cualquier CMS, patrones de más de 50 integraciones de stacks
- 1.El patrón general: primero la abstracción de handlers
- 2.Contentful: patrones y anti-patrones
- 3.Storyblok: patrones y anti-patrones
- 4.Hygraph: patrones y anti-patrones
- 5.Sanity: patrones y anti-patrones
- 6.Strapi: patrones y anti-patrones
- 7.DatoCMS: patrones y anti-patrones
- 8.Prismic: patrones y anti-patrones
- 9.El cuadro común: qué hace real el agnosticismo respecto al CMS
Laioutr opera frontends sobre más de 50 stacks de backend diferentes. Siete de los sistemas headless CMS más utilizados en el contexto enterprise de la región DACH forman parte de ese conjunto. Los patrones que funcionan no son específicos de un CMS, y los anti-patrones que generan problemas se repiten de un sistema a otro.
Este artículo es documentación directa de esas integraciones. No es una "comparativa de los 7 mejores CMS", las tablas de precios las puede leer cualquiera. Lo que aquí se documenta es otra cosa: qué decisiones de la capa de frontend son correctas en cada CMS, y dónde los equipos construyen sistemáticamente el mismo problema.
El patrón general: primero la abstracción de handlers
Antes de recorrer los patrones específicos de cada CMS, el principio general que se sostiene en las más de 50 integraciones:
La capa de frontend no debería llamar directamente a un SDK de CMS. Debería llamar a un content handler abstracto que encapsule ese SDK. Esa es la diferencia entre un frontend profundamente casado con un CMS y otro que es agnóstico al CMS.
// Anti-patrón: SDK directamente en el código de la página
import { createClient } from 'contentful'
// o
import { createClient } from '@sanity/client'
// o
import { GraphQLClient } from 'graphql-request' // hygraph
// Los tres son anti-patrones a nivel de página
// Patrón: interfaz abstracta a nivel de plataforma
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>
}Con esta interfaz como base, estos son los siete sistemas CMS.
Contentful: patrones y anti-patrones
Lo que absorbe la capa de frontend:
La Content Delivery API de Contentful admite GraphQL (respaldada por CDN), pero entrega el contenido en un formato propio de Contentful: wrappers `sys`, wrappers `fields`, referencias a assets embebidas con su propia comprobación `sys.type = 'Asset'`. Esa sobrecarga de formato no pertenece al código de los componentes.
// Contentful handler: normaliza el formato de 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', // ¡mapeo de locales de 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)
})
}
}El anti-patrón clásico de Contentful:
Renderizar rich text directamente en los componentes, sin normalizador. El rich text de Contentful es un AST en JSON (similar a Slate.js), pero con tipos de nodo propios de Contentful para las entradas embebidas. Llamar a `documentToReactComponents(richText)` directamente en un componente incorpora una dependencia dura de Contentful en la capa de UI.
La solución: un renderizador de rich text propio a nivel de plataforma que convierta el AST de Contentful en un AST de componentes normalizado.
Storyblok: patrones y anti-patrones
Lo que absorbe la capa de frontend:
Storyblok trabaja con bloques anidados (componentes) en lugar de tipos de contenido. Eso es conceptualmente distinto de Contentful: Storyblok piensa en "stories" compuestas de "blocks". Cada block tiene una clave `component` que indica qué componente de UI hay que renderizar.
El patrón de Storyblok que funciona:
// 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 da directamente el nombre del componente
data: this.normaliseBlockData(block),
id: block._uid
}))
}
}El anti-patrón clásico de Storyblok:
Integrar la directiva `storyblokEditable` directamente en los componentes de UI finales. `storyblokEditable(blok)` añade atributos `data-` para el overlay del Visual Editor de Storyblok. Eso acopla los componentes de UI a la infraestructura de edición de Storyblok. Cuando el editor se traslada a otra plataforma (o se desactiva), quedan directivas muertas en el output del render.
La solución: detectar el modo editor a nivel de plataforma, no a nivel de componente. Cuando se cumple `isEditorMode()`, las directivas las inyecta el handler, y los componentes se mantienen limpios.
Hygraph: patrones y anti-patrones
Lo que absorbe la capa de frontend:
Hygraph es GraphQL nativo. Eso es una ventaja, pero tienta a los equipos a escribir queries de GraphQL directamente en los componentes. Para una arquitectura agnóstica al CMS, ese es el mismo anti-patrón que con cualquier otro SDK.
La fortaleza de Hygraph es su capa de GraphQL con capacidad de federación: un único endpoint de GraphQL puede agregar varias fuentes de backend. Para frontends con varias fuentes de datos (CMS + commerce + búsqueda), eso es una ventaja real, pero solo cuando la capa de frontend está limpiamente abstraída del esquema de GraphQL.
// 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 }
}
}
}
// La query se queda en el handler, no en el componente
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 ... } }
}
}
`El anti-patrón de Hygraph:
Configurar mal el fallback de locales de Hygraph. Hygraph tiene un sistema explícito de fallback de locales: si una entrada no existe en el locale solicitado, puede recurrir a un locale de reserva. No configurarlo da como resultado campos vacíos en lugar de contenido de fallback. Esto ocurre sobre todo con entradas heredadas que no tienen la localización completa.
La solución: definir `locales: [$primaryLocale, en]` en la query (Hygraph admite arrays de locales como lista de prioridad).
Sanity: patrones y anti-patrones
Lo que absorbe la capa de frontend:
Sanity tiene GROQ, su propio lenguaje de consulta, más potente que GraphQL pero con una curva de aprendizaje más pronunciada. Para las personas de frontend sin experiencia en GROQ, la tentación es escribir queries GROQ directamente en los server components de Next.js. Ese es el equivalente en Sanity del anti-patrón del SDK de Contentful.
// Sanity handler
class SanityHandler implements CMSHandler {
async fetchPage(slug: string, locale: string): Promise<PageData> {
// GROQ se queda en el handler, no en el componente
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)
}
}El anti-patrón de Sanity:
Renderizar el Portable Text de Sanity directamente en los componentes, sin una capa de render normalizada. El Portable Text de Sanity es un formato de contenido por bloques con definiciones de marca propias para enlaces inline, anotaciones personalizadas y objetos embebidos. Usar `<PortableText value={portableText} />` directamente en un componente convierte ese componente en específico de Sanity.
La solución: un renderizador propio de Portable Text a nivel de plataforma, con tipos de bloque normalizados, que convierta la entrada específica de Sanity en un output agnóstico al CMS.
Strapi: patrones y anti-patrones
Lo que absorbe la capa de frontend:
Strapi tiene una API REST (la principal) y una API GraphQL (opcional, vía plugin). Para los setups enterprise, la API GraphQL es preferible, pero los setups de Strapi varían bastante según la versión (v4 frente a v5) y la configuración de plugins. La capa de handler tiene que encapsular esa variación.
Strapi v5 introdujo una nueva Document Service API con `documentId` en lugar de `id`. Llamar a la API directamente desde el frontend, sin capa de handler, significa breaking changes en cada actualización de Strapi.
// 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 en lugar de id
title: page.title,
slug: page.slug,
blocks: this.normaliseStrapiBlocks(page.blocks),
meta: page.seo ?? {}
}
}
}El anti-patrón de Strapi:
Descubrir los errores de permisos de Strapi en runtime. Strapi tiene un sistema de permisos granular, los endpoints públicos deben habilitarse de forma explícita. No probarlo durante el desarrollo se traduce en errores 403 en producción para tipos de contenido que sí son visibles en el admin del CMS pero no son accesibles públicamente.
La solución: validar los permisos de Strapi como parte de los tests de setup del handler (comprobación de accesibilidad del endpoint durante la inicialización del handler).
DatoCMS: patrones y anti-patrones
Lo que absorbe la capa de frontend:
DatoCMS tiene una API GraphQL especialmente limpia y una buena librería cliente de TypeScript. Eso favorece el acoplamiento estrecho: trabajar con DatoCMS resulta tan agradable que el anti-patrón aparece de forma sutil, el handler se queda demasiado fino porque parece que la API necesita poca normalización.
Hasta que cambia el modelo de contenido. DatoCMS permite cambios en el modelo de contenido sin migraciones de esquema (a diferencia de Contentful). Eso es una feature, pero implica que el handler tiene que comprobar de forma explícita los cambios de modelo, porque el cliente de TypeScript no valida el esquema automáticamente.
// DatoCMS handler con schema guard explícito
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)
// Schema guard explícito: comprobar los campos críticos
if (!page.title || !page.slug) {
throw new SchemaValidationError(`Page ${slug}: missing required fields`)
}
return this.normaliseDatoCMSPage(page)
}
}El anti-patrón de DatoCMS:
El modo preview de DatoCMS sin abstracción de handler. DatoCMS tiene su propio modo preview, que funciona con `previewSecret` y una ruta `/api/preview`. Implementarlo directamente en las páginas de Next.js, sin abstracción de handler, da como resultado una lógica de preview específica del CMS. Cambiar de CMS significa reimplementar el preview desde cero.
La solución: abstraer el modo preview a nivel de handler, `handler.getPreviewData(slug)` devuelve los datos de preview con independencia del mecanismo de preview del CMS.
Prismic: patrones y anti-patrones
Lo que absorbe la capa de frontend:
Prismic tiene su propio concepto de slices para construir páginas, las páginas se componen de "slices" que representan distintos tipos de componente. Eso es conceptualmente similar a Storyblok, pero con su propio flujo de trabajo SliceMachine para la generación de código.
// 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
}
}
}El anti-patrón de Prismic:
Usar los componentes generados por SliceMachine directamente como componentes de UI finales. SliceMachine genera componentes React con prop types propios de Prismic. Integrar esos componentes directamente en el design system de UI (en lugar de usarlos como adaptadores que pasan props normalizadas a los componentes del design system) incorpora los tipos de Prismic a la librería de componentes.
La solución: tratar los componentes de SliceMachine como una capa de adaptación, traducen las props de Prismic a props del design system y después llaman a componentes genéricos del design system.
El cuadro común: qué hace real el agnosticismo respecto al CMS
En los siete sistemas CMS los patrones son consistentes:
Lo que funciona en todos los casos:
- Abstracción de handlers a nivel de plataforma (no a nivel de página ni de componente)
- Interfaz ContentEntry normalizada que encapsula los formatos propios de cada CMS
- Mapeo de locales en el handler (cada CMS tiene sus propias convenciones de locales)
- Modo preview como responsabilidad del handler, no de la página
- Schema guards para los campos críticos (protegen frente al desvío del modelo)
Lo que causa problemas en todos los casos:
- Llamadas al SDK directamente en componentes o páginas
- Renderizado de rich text sin un renderizador normalizado
- Lógica de preview específica del CMS en la capa de UI
- Strings de locale sin lógica de mapeo (cada CMS usa códigos de locale distintos)
- Directivas de modo editor en los componentes de UI finales
Las más de 50 integraciones de stacks sobre la plataforma Composable Headless Frontend se construyen con este patrón. El CMS es una decisión de implementación intercambiable, no una decisión de arquitectura.
[Marcel section]
Este es el núcleo de lo que en Laioutr entendemos por "headless cms agnostic". No: "damos soporte a muchos sistemas CMS". Sino: "la capa de frontend está construida de modo que el CMS es un componente configurable, no una dependencia estructural".
Si quieres evaluar el esfuerzo concreto de ingeniería para tu stack, cuál de los siete patrones de handler es relevante para tu setup, cómo sería el trabajo de abstracción sobre el código de frontend existente, una conversación técnica con Sebastian es el camino más directo.
El concepto Agentic Frontend Management Platform es el marco de referencia; la implementación del handler es el trabajo concreto. Podemos recorrer ambas cosas contigo.
[End Marcel section]
Más sobre esta capacidad: Content Management
Relacionado: Sin cambio de CMS, sin reescritura del storefront · Cuando tu capa de CMS cambia de manos · Headless CMS para eCommerce con Next.js en 2026