La Content Layer API: qué es y por qué
El salto del modelo antiguo de content collections —atado a la carpeta mágica src/content— a la Content Layer API de Astro 7: colecciones definidas por un loader que nombra su fuente, un store normalizado en tiempo de build y una escalabilidad que leer una carpeta nunca tuvo.
Dos modelos, una misma forma de consumir
Para medir el cambio conviene recordar el modelo que sustituye. Hasta Astro 5, una colección era, literalmente, una carpeta. El contenido tenía que vivir en src/content/<coleccion>, el fichero de configuración se llamaba src/content/config.ts, y cada colección se declaraba con un type que solo admitía dos valores: content para Markdown y data para JSON o YAML. Astro recorría esa carpeta por convención, y esa convención era a la vez su comodidad y su cárcel: el contenido no podía estar en otro sitio ni venir de otra parte que no fuera el disco local.
// Modelo antiguo (Astro < 5): la carpeta mandaimport { defineCollection, z } from 'astro:content';const blog = defineCollection({ type: 'content', // 'content' | 'data' schema: z.object({ title: z.string(), pubDate: z.date() }),});export const collections = { blog };La Content Layer invierte ese planteamiento. En lugar de un type que elige entre dos formatos locales, cada colección recibe un loader: una pieza que sabe traer contenido de donde sea. El fichero se muda a src/content.config.ts —fuera de la carpeta content, porque ya no depende de ella— y el type desaparece, absorbido por el loader. Lo esencial es que el loader es un contrato abierto: glob y file son los dos que Astro trae de fábrica, pero cualquiera puede escribir el suyo, y todos rellenan el mismo almacén.
// Content Layer (Astro 7): el loader mandaimport { defineCollection, z } from 'astro:content';import { glob } from 'astro/loaders';const blog = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/blog' }), schema: z.object({ title: z.string(), pubDate: z.date() }),});export const collections = { blog };Lo que no cambia —y esto es deliberado— es cómo consumes la colección. Sigues llamando a getCollection('blog') y a getEntry, sigues recibiendo entradas tipadas contra tu esquema. La API de consumo es estable a propósito, porque el objetivo de toda la reforma no es que aprendas nuevas funciones para leer datos, sino que la fuente de esos datos pueda cambiar sin que tu código se entere. Debajo, en cambio, todo es distinto: la carpeta mágica se ha convertido en una abstracción sobre orígenes.
El consumo es estable, pero no idéntico al de las collections antiguas, y conviene anotar dos ajustes que la migración trajo. El método entry.render() dio paso a una función render que importas de astro:content y a la que pasas la entrada; y el antiguo campo slug, que solo tenían las collections de tipo content, se unificó bajo el id que ahora llevan todas. Son cambios pequeños en la superficie que reflejan la unificación de fondo: ya no hay dos clases de entrada —contenido y datos— sino una sola forma común, con id para todas y cuerpo solo para las que lo tengan.
import { getEntry, render } from 'astro:content';const entrada = await getEntry('blog', 'mi-post');const { Content } = await render(entrada); // antes: entrada.render()En Astro 7 esto no es una opción entre dos modelos: el antiguo se retiró por completo y type: 'content' ya no existe. Toda colección pasa por un loader, y los proyectos que venían de versiones anteriores tuvieron que migrar su config.ts a content.config.ts y su type a un glob loader equivalente. Lo que empezó como una alternativa opcional en Astro 5 es hoy el único camino, y esa consolidación es la que justifica dedicarle un nivel entero.
El store: contenido normalizado en el build
Toda la Content Layer gira alrededor de una estructura que el modelo antiguo no exponía: el store, un almacén de datos —un DataStore— donde los loaders depositan sus entradas. Cuando arranca el servidor de desarrollo o se lanza un build, Astro ejecuta el loader de cada colección; el loader hace su trabajo —leer ficheros, llamar a una API— y escribe el resultado en el store como un conjunto de entradas planas. Solo después, a partir de ese store ya poblado, Astro genera los tipos y sirve getCollection.
Cada entrada del store tiene una forma canónica, independiente de su origen: un id que la identifica, un data con los campos ya validados y —si el contenido lo tiene— un body con el texto sin renderizar y un rendered con su HTML. Da igual que la entrada venga de un Markdown local o de un JSON remoto: al llegar al store, ambas comparten la misma estructura. Esa normalización es lo que permite que una sola API de consumo sirva para cualquier fuente.
El store, además, no es efímero. Astro lo persiste entre builds en la carpeta .astro, en un fichero de datos que sobrevive de una compilación a la siguiente. Esa persistencia es la semilla del caché incremental que verás al final del nivel: un loader bien escrito puede consultar el store previo, comparar lo que ya tenía con lo que la fuente ofrece ahora y actualizar solo lo que cambió, en vez de reconstruirlo todo. El modelo antiguo releía la carpeta entera en cada build; el store abre la puerta a no repetir trabajo.
Conviene ver el store como lo que es: una pequeña base de datos de build-time. No es una caché opaca ni un detalle de implementación que puedas ignorar, sino la pieza central sobre la que los loaders operan con un puñado de métodos —store.set, store.get, store.entries, store.clear—. Cuando en las próximas lecciones escribas tu propio loader, tu trabajo consistirá, en esencia, en llenar bien ese store; todo lo demás —tipos, validación, consulta— Astro lo construye encima de él.
Ese paso de generar los tipos tiene nombre propio: astro sync, que el framework dispara solo al arrancar y cada vez que cambias la configuración. Recorre el store ya poblado y emite las definiciones de TypeScript que hacen que data tenga forma en cada colección. Por eso el autocompletado de getCollection('blog') conoce los campos sin que instales nada: se derivan del contenido que el loader depositó y del esquema que lo validó. Si alguna vez el editor parece no ver un campo que sí declaraste, forzar astro sync suele realinear los tipos con la realidad.
Conviene precisar que el store no es un único saco global, sino que está particionado por colección: cada una tiene su propio espacio de entradas y de claves. Esa partición es la que permite que dos colecciones compartan un mismo id sin pisarse, y que Astro recargue una colección sin tocar las demás. El store es, más que una base de datos monolítica, un conjunto de pequeñas tablas independientes, una por cada nombre que declaraste en collections.
La fuente, desacoplada del consumo
El verdadero motivo de este rediseño es la escala, entendida en dos ejes. El primero es el volumen: un sitio con decenas de miles de entradas no puede permitirse releer y reparsear todo su contenido en cada build. Con el store persistente y los loaders incrementales, el trabajo se vuelve proporcional a lo que cambia, no a lo que existe. Un cambio en tres ficheros no debería costar lo mismo que arrancar de cero, y con la Content Layer no lo cuesta.
El segundo eje es el origen. Atado a src/content, el modelo antiguo solo sabía de ficheros locales; traer datos de un CMS o de una API obligaba a rodeos que quedaban fuera del sistema de collections, sin sus tipos ni su validación. El loader disuelve esa frontera: como es una simple pieza que rellena el store, da igual que lo haga leyendo un disco o llamando a un servicio remoto. El contenido de terceros entra por la misma puerta que el local y sale igual de tipado.
Hay un tercer beneficio, más silencioso, que se deriva de los dos anteriores: la homogeneidad. Cuando todo el contenido —local y remoto, mucho y poco— entra por el mismo contrato y sale con la misma forma, tu código deja de tratar cada fuente como un caso especial. Un componente que pinta la tarjeta de un post no necesita saber si ese post vino de un Markdown o de un CMS: recibe una entrada tipada y la pinta. Esa uniformidad es la que permite que un sitio crezca sin multiplicar las ramas de código que lo sostienen.
Este desacople tiene un nombre en arquitectura: es una capa de indirección entre la fuente y el consumidor. Y como toda indirección bien puesta, su valor no está en lo que te deja hacer hoy, sino en lo que te deja cambiar mañana sin romper nada. Empiezas con un blog de Markdown local; dentro de un año lo migras a un CMS porque el equipo de contenido creció. Con el modelo antiguo, esa migración tocaba cada plantilla; con la Content Layer, cambia un loader en content.config.ts y nada más se entera.
Un desacople que también cuesta
Sería deshonesto vender la Content Layer como puro beneficio. Toda indirección tiene un precio, y el suyo se paga en dos monedas. La primera es conceptual: hay más piezas que entender —loader, store, meta, digest— que en el viejo “pon tus ficheros en esta carpeta”. Para un sitio diminuto, esa carga cognitiva puede no compensar, y no pasa nada por reconocerlo.
La segunda moneda es la del caché. Un store que persiste entre builds es una fuente de velocidad, pero también de estados sutiles: un caché desactualizado puede servir datos viejos, y depurar “por qué veo contenido que ya borré” exige saber que existe una capa de memoria en .astro que a veces hay que limpiar. El modelo antiguo, que releía todo cada vez, era más lento pero más ingenuo: no tenía una memoria que pudiera mentir.
Nada de esto invalida el diseño; solo lo sitúa. La Content Layer optimiza para el sitio que crece —más contenido, más fuentes, más gente— y acepta a cambio algo más de maquinaria. Es el trueque habitual de la ingeniería: pagas algo de complejidad hoy para comprar escala mañana. Conocer ese precio es justo lo que te permite decidir con criterio cuándo merece la pena y cuándo un simple glob sobre una carpeta local es más que suficiente.