wandres.dev
ARQUITECTURA A ESCALA · proyectos grandes

Estrategia de contenido a escala

Cuando el contenido pasa de un puñado de ficheros a decenas de colecciones alimentadas por un CMS, hace falta estrategia: organizar content.config.ts con esquemas que se referencian, escribir loaders propios que traigan datos remotos con caché incremental, servir el sitio en varios idiomas con el i18n de Astro, y generar miles de páginas sin que el build se ahogue mezclando estático, bajo demanda y server islands.

⏱ 20 min

Diez ficheros Markdown se gestionan con las manos; diez mil, no. Cuando un sitio de contenido crece de verdad, aparecen problemas que no existían a pequeña escala: decenas de colecciones que se referencian entre sí, contenido que ya no vive en tu repositorio sino en un CMS que un equipo editorial alimenta, el mismo artículo en cinco idiomas, y un build que ha de fabricar decenas de miles de páginas sin tardar una hora. La Content Layer API de Astro fue diseñada justo para esta escala: separa de dónde viene el contenido de cómo se consume, y coloca un almacén tipado en medio. Este capítulo usa esa arquitectura para llevar un sitio de contenido de la escala del juguete a la escala industrial.

🎯 Al terminar esta lección sabrás
  • Organizar content.config.ts con varias colecciones que se referencian con reference.
  • Escribir un loader propio que traiga contenido de un CMS con caché incremental.
  • Servir el sitio en varios idiomas con la configuración i18n de Astro.
  • Generar miles de páginas combinando estático, bajo demanda y server islands.

Muchas colecciones que se referencian

A escala, el contenido deja de ser una sola colección de posts y se vuelve un pequeño modelo de datos: posts, autores, categorías, series, páginas de aterrizaje. Todo eso se declara en src/content.config.ts, y la tentación de dejarlo crecer como un único fichero de mil líneas se combate con el mismo criterio del nivel: cada colección define su esquema, y las relaciones entre ellas se expresan con reference, que enlaza una entrada con otra de otra colección sin duplicar datos.

// src/content.config.ts
import { defineCollection, reference, z } from 'astro:content';
import { glob } from 'astro/loaders';

const posts = defineCollection({
  loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }),
  schema: z.object({
    title: z.string(),
    lang: z.enum(['es', 'en', 'pt']),
    autor: reference('autores'),
    relacionados: z.array(reference('posts')).default([]),
  }),
});

const autores = defineCollection({
  loader: glob({ pattern: '**/*.json', base: './src/content/autores' }),
  schema: z.object({ nombre: z.string(), bio: z.string() }),
});

export const collections = { posts, autores };

Un reference('autores') no copia el autor dentro del post: guarda una referencia que luego resuelves con getEntry, de modo que el nombre del autor vive en un solo sitio y cambiarlo actualiza todos sus posts. Ese modelado relacional —normalizar, referenciar, resolver— es lo que distingue un contenido que escala de uno que se repite a sí mismo hasta volverse incoherente. Cuando la referencia falla porque el autor no existe, el esquema lo detecta en el build, no en producción.

Loaders: del glob local al CMS remoto

El glob que has visto es un loader, y ahí está la idea que abre la puerta a la escala: una colección no está atada a ficheros locales. Un loader es cualquier cosa que sepa poblar el almacén de contenido, así que el mismo posts que hoy lee .mdx de tu disco puede mañana leer de un CMS remoto sin que las páginas que lo consumen cambien una línea. Escribir un loader propio es implementar un objeto con un name y una función load que recibe el store y lo llena.

// src/loaders/cms.ts
import type { Loader } from 'astro/loaders';

export function cmsLoader(config): Loader {
  return {
    name: 'cms-loader',
    load: async ({ store, meta, parseData, generateDigest, logger }) => {
      const desde = meta.get('ultima-sync');
      const items = await fetch(config.endpoint + '?desde=' + desde)
        .then((r) => r.json());
      logger.info('recibidos ' + items.length + ' items del cms');
      for (const item of items) {
        const data = await parseData({ id: item.slug, data: item });
        store.set({ id: item.slug, data, digest: generateDigest(data) });
      }
      meta.set('ultima-sync', String(Date.now()));
    },
  };
}

Dos piezas hacen a este loader apto para la escala. El digest es una huella del contenido de cada entrada: si Astro ve que el digest no cambió, se ahorra reprocesar esa entrada, así que un sitio con miles de páginas solo recalcula las que de verdad cambiaron. Y el meta, un pequeño almacén que persiste entre builds, permite guardar una marca de tiempo y pedir al CMS solo lo modificado desde la última sincronización. Juntos convierten un build ingenuo que se lo trae todo cada vez en uno incremental que toca lo mínimo, que es la única forma de que un sitio grande siga construyéndose en segundos.

ℹ️
El store es la frontera que desacopla origen y consumo

La razón de que puedas cambiar de ficheros locales a un CMS sin tocar las páginas es que ninguna página habla nunca con el origen: hablan con el almacén, siempre, a través de getCollection y getEntry. El loader llena el almacén; la página lo lee. Esa frontera es la misma idea de fachada que recorre todo el nivel, aplicada al contenido: el origen es un detalle intercambiable detrás de una interfaz estable. Un sitio puede incluso mezclar orígenes —parte en Markdown, parte en un CMS, parte en una API— y consumirlos con la misma llamada, porque todos desembocan en el mismo store tipado.

i18n: el sitio en varios idiomas

Un sitio de contenido serio suele hablar más de un idioma, y Astro trae el enrutado internacional de fábrica. Se declara en la configuración: un defaultLocale, la lista de locales, y una estrategia de rutas que decide si el idioma por defecto lleva prefijo en la URL o no.

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  i18n: {
    defaultLocale: 'es',
    locales: ['es', 'en', 'pt'],
    routing: { prefixDefaultLocale: false },
  },
});

Con esto, /blog/hola sirve el español y /en/blog/hello el inglés, y Astro te da utilidades para saber en qué idioma estás y construir enlaces al equivalente en otros. La estrategia de contenido que mejor escala combina esto con el campo lang del esquema: cada entrada declara su idioma, y una ruta dinámica bajo [lang] filtra la colección por idioma para generar solo las páginas de cada locale. Así el modelo de datos y el enrutado hablan el mismo lenguaje, y añadir un idioma es traducir entradas, no reescribir plantillas.

Generar miles de páginas sin ahogar el build

La ruta dinámica es la máquina que convierte contenido en páginas. Un solo fichero con getStaticPaths recorre la colección entera y emite una página por entrada: es como esta misma guía fabrica cientos de lecciones desde una plantilla.

---
// src/pages/[lang]/blog/[...slug].astro
import { getCollection, getEntry, render } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('posts');
  return posts.map((post) => ({
    params: { lang: post.data.lang, slug: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const autor = await getEntry(post.data.autor);
const { Content } = await render(post);
---
<h1>{post.data.title}</h1>
<p>por {autor.data.nombre}</p>
<Content />

A escala de decenas de miles de páginas, generarlas todas en el build tiene un límite: el tiempo. La estrategia madura no es horneral todo por sistema, sino repartir. Las páginas que importan por SEO y cambian poco —el grueso del contenido— se generan estáticas. La cola larga que casi nadie visita puede servirse bajo demanda con un adaptador, generándose en la primera visita y cacheándose después, de modo que el build no paga por millones de páginas que quizá nadie pida. Y dentro de una página mayormente estática, el trozo que sí es dinámico —un contador de vistas, una recomendación personalizada— se aísla en una server island que se renderiza aparte sin volver dinámica la página entera.

flowchart LR
MD[markdown local] --> LG[glob loader]
CMS[cms remoto] --> LC[loader propio]
API[api de datos] --> LC
LG --> ST[store unico tipado]
LC --> ST
ST --> GP[getStaticPaths]
GP --> EST[paginas estaticas]
ST --> DEM[cola larga bajo demanda]
style ST fill:#f9e2af,color:#11111b
style EST fill:#a6e3a1,color:#11111b
style CMS fill:#89b4fa,color:#11111b
⚠️
Miles de páginas exigen un build incremental, no fuerza bruta

El error que revienta un sitio grande es tratar el build como una operación de todo o nada: releer cada fichero, reprocesar cada entrada, regenerar cada página en cada despliegue. A cinco mil páginas eso son minutos que crecen con el contenido y acaban asfixiando la iteración. La salida es apoyarse en lo que la Content Layer ya ofrece —digest para no reprocesar lo intacto, meta para pedir al origen solo lo cambiado— y reservar la generación bajo demanda para lo que no merece un hueco en el build. Un sitio que escala no construye más rápido porque tenga una máquina mayor: construye menos porque sabe qué no ha cambiado.

🔗

Colecciones que se referencian

reference enlaza posts con autores y tags sin duplicar; getEntry resuelve la referencia al renderizar.

🔌

Loaders propios

Un objeto con name y load llena el store desde un cms o una api; las paginas no notan el cambio de origen.

🌍

i18n de fabrica

defaultLocale, locales y la estrategia de rutas; el campo lang del esquema alimenta una ruta bajo lang.

🏗️

Reparte la generacion

Estatico para lo que importa, bajo demanda para la cola larga, server islands para el trozo dinamico.

A escala, el contenido es una base de datos disfrazada de ficheros

El salto mental que exige el contenido a escala es dejar de ver Markdown y empezar a ver datos. Un puñado de ficheros invita a pensar en documentos: cada uno es una cosa que se escribe y se lee entera. Pero diez mil entradas con autores, categorías, traducciones y relaciones cruzadas ya no son documentos, son registros, y el sitio que los sirve es, lo admita o no, un sistema de base de datos con una capa de presentación encima. La Content Layer API es Astro reconociendo exactamente eso. El store es una tabla; el esquema de Zod es el DDL que define y valida las columnas; reference es una clave foránea; el digest es la detección de cambios que evita reindexar lo que sigue igual; el meta es el estado que permite una sincronización incremental en vez de un volcado completo. Vista así, cada decisión de escala deja de ser un truco de Astro y se revela como un principio de bases de datos con un siglo de historia: normaliza para no repetir, referencia en vez de copiar, indexa lo que consultas, materializa lo caro y calcula bajo demanda lo raro, invalida por huella y no por reloj. El origen del contenido —ficheros, CMS, API— es entonces solo el motor de almacenamiento, intercambiable detrás de la misma interfaz de consulta, y los idiomas no son plantillas duplicadas sino una dimensión más del modelo. Cuando dejes de preguntarte “¿cómo renderizo este Markdown?” y empieces a preguntarte “¿cuál es el modelo de datos de mi contenido y cómo lo consulto sin reprocesarlo entero?”, habrás cruzado del artesano que edita ficheros al arquitecto que gobierna un corpus. La escala no cambia lo que es el contenido; cambia lo que tienes que admitir que siempre fue.

⚔️ Lleva una colección a escala
  1. Declara en content.config.ts dos colecciones, posts y autores, y enlázalas con reference; resuelve el autor en la página con getEntry.
  2. Escribe un loader propio que traiga entradas de una API y las meta en el store usando parseData y un digest; guarda una marca en meta para pedir solo lo nuevo.
  3. Activa i18n con dos idiomas y genera las páginas de cada uno filtrando la colección por su campo lang en getStaticPaths.
  4. Marca una parte de la página como candidata a servirse bajo demanda o como server island, y razona qué contenido merece build estático y cuál no.