Email copiado — support@tuurt.com
Cargando experiencia
astro · 24 de septiembre de 2026 · 7 min

Content Collections en Astro: tipar el contenido del blog con Zod antes de que llegue a produccion

Un frontmatter sin validar falla en produccion cuando alguien visita la pagina rota. Vemos como Content Collections y un esquema Zod mueven ese error al momento del build, y por que z.coerce.date() evita el fallo mas comun con las fechas.

Por Equipo Tuurt

Content Collections en Astro: tipar el contenido del blog con Zod antes de que llegue a produccion

Un blog en Astro construido sobre archivos markdown sueltos funciona bien los primeros meses. Cada post es un archivo con frontmatter al principio y el contenido debajo, y las paginas lo leen con Astro.glob() o con import.meta.glob(), confiando en que cada campo del frontmatter este escrito como se espera. El problema no aparece en el primer post ni en el decimo. Aparece cuando el numero de archivos crece, entran mas personas a escribir contenido, y un campo se escribe distinto en un archivo que en los demas: date en uno, pubDate en otro, una fecha como texto libre en vez de fecha real, un campo tags que en un post es un string separado por comas y en otro ya es un array. Nada de esto falla en el momento de guardar el archivo. Falla en tiempo de ejecucion, cuando una pagina intenta leer post.data.pubDate.getFullYear() sobre un valor que en realidad es un string, o cuando tags.map() explota porque tags no es un array.

Por que el frontmatter sin esquema falla tarde

El markdown suelto no tiene ningun mecanismo que valide su propia estructura. TypeScript puede tipar el componente que consume los datos, pero no puede tipar el contenido de un archivo .md que se lee dinamicamente con un glob: para TypeScript, el resultado de ese glob es, en el mejor de los casos, any, o un tipo generico que no sabe nada de los campos reales que existen en cada post. El error de un campo mal escrito no se detecta al escribir el codigo ni al hacer tsc, se detecta -si se detecta- cuando esa pagina especifica se renderiza, ya sea en desarrollo local si alguien la visita, o en produccion si nadie la visito antes del deploy. Con un blog SSR esto es particularmente incomodo, porque el error no rompe el build: la aplicacion despliega sin problema y la ruta rota solo se manifiesta cuando alguien pide esa URL puntual.

Content Collections: el esquema vive en el codigo, no en la convencion

Content Collections resuelve esto moviendo la validacion del frontmatter de "confiar en que todos los archivos siguen la misma convencion" a "declarar el esquema una vez y dejar que Astro rechace cualquier archivo que no lo cumpla". El esquema se define con Zod en src/content.config.ts:

import { defineCollection, z, reference } 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(),
    excerpt: z.string().max(280),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    draft: z.boolean().default(false),
    tags: z.array(z.string()).min(1),
    author: reference('authors'),
  }),
});

export const collections = { blog };

Cada campo tiene un tipo declarado, y Zod no solo verifica el tipo sino que puede imponer reglas adicionales: excerpt no puede pasar de 280 caracteres, tags no puede ser un array vacio, draft tiene un valor por defecto si el archivo no lo especifica. El campo author no es un string libre sino una referencia tipada a otra coleccion, authors, lo que significa que si un post referencia un autor que no existe en esa coleccion, Astro lo marca como error en vez de dejar pasar un string suelto que nunca se resuelve a nada.

La diferencia frente al glob suelto no es solo cosmetica. En cuanto se ejecuta astro build o astro check, Astro valida cada archivo de la coleccion contra el esquema y falla el build completo si alguno no cumple, con un mensaje que apunta al archivo y al campo especifico que no paso la validacion. El error de un pubDate escrito como "24 de septiembre" en vez de una fecha real deja de ser un bug silencioso que aparece cuando alguien visita esa pagina, y pasa a ser un fallo de build que bloquea el deploy hasta que se corrija.

El detalle que importa: coercion de fechas

El punto donde mas facil es equivocarse al escribir el esquema es la fecha. YAML permite escribir una fecha en frontmatter de dos formas que Zod trata de manera distinta: pubDate: 2026-03-14 sin comillas, que YAML interpreta como un objeto Date nativo, o pubDate: "2026-03-14" entre comillas, que YAML interpreta como un string plano. Si el esquema usa z.date() a secas, el primer formato pasa la validacion sin problema, pero el segundo falla, porque z.date() espera recibir ya un objeto Date, no un string que se le parezca. Como en la practica es imposible garantizar que todo el equipo escriba siempre fechas sin comillas -y algunos editores de markdown agregan comillas automaticamente-, la version que funciona de forma consistente es z.coerce.date(), que acepta tanto el objeto Date nativo como un string parseable y lo normaliza a Date en ambos casos. Cambiar z.date() por z.coerce.date() en el esquema de arriba elimina una clase entera de fallos de build que no tienen que ver con el contenido real del post, sino con si alguien puso comillas o no alrededor de la fecha.

Consumir la coleccion con tipos ya resueltos

Del lado de las paginas, getCollection devuelve entradas con el tipo del esquema ya aplicado, sin necesidad de castear ni de validar de nuevo:

import { getCollection, render } from 'astro:content';

const posts = await getCollection('blog', ({ data }) => !data.draft);

const ordenados = posts.sort(
  (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);

El segundo argumento de getCollection filtra en el momento de la carga, no despues, asi que un post con draft: true nunca llega a construirse como pagina publica en primer lugar. Y como data.pubDate ya es un Date real gracias a la coercion, .valueOf() funciona sin que haga falta un new Date(post.data.pubDate) defensivo en cada lugar donde se usa la fecha, que era exactamente el tipo de codigo repetido que el glob suelto obligaba a escribir en cada pagina que tocaba fechas.

Lo que no funciono: validar a mano con una funcion de guardia

Antes de migrar a Content Collections probamos una salida intermedia: mantener el glob suelto pero agregar una funcion esFrontmatterValido() que revisaba los campos esperados antes de renderizar cada post, y loggeaba una advertencia si algo no encajaba. La idea era detectar el problema sin reescribir la forma en que se leia el contenido. No funciono por dos razones. La primera es que la funcion de guardia vivia separada de donde se usaban los datos, asi que cada vez que se agregaba un campo nuevo al frontmatter habia que acordarse de actualizar tambien la funcion de validacion, y en la practica se desincronizaban: la funcion validaba campos que ya no se usaban y dejaba pasar campos nuevos sin chequear. La segunda es que una advertencia en log durante el build no bloquea nada: el deploy sigue adelante igual, y una advertencia que no bloquea termina siendo una advertencia que nadie lee, sobre todo en un pipeline con muchas lineas de output. Content Collections resuelve ambos problemas a la vez porque el esquema y el contenido viven acoplados por diseno -no hay forma de que se desincronicen sin que el build lo note- y porque la validacion fallida es un error de build, no una linea de log opcional.

Cuando vale la pena migrar

Para un blog con pocos posts y una sola persona escribiendo, el costo de definir un esquema Zod puede no justificarse todavia: los errores de frontmatter son raros porque la misma persona que escribe el contenido conoce de memoria la convencion. La migracion empieza a pagarse sola en cuanto entra una segunda persona a escribir contenido, o en cuanto el numero de campos en el frontmatter crece lo suficiente como para que nadie los recuerde todos de memoria. En ese punto, la pregunta relevante no es si va a haber un error de frontmatter, sino si ese error se va a descubrir en el build, antes de que nadie lo vea, o en produccion, cuando alguien pida la pagina rota.

astro typescript content-collections zod
← Volver al blog