wandres.dev
CONTENT LAYER API · loaders personalizados

Los loaders integrados: glob() y file()

Los dos loaders que Astro trae de fábrica: glob para muchos ficheros y file para un fichero con una lista dentro. Patrones de rutas con pattern y base, el parseo según la extensión —Markdown frente a datos— y cómo se deriva el id de cada entrada en Astro 7.

⏱ 15 min

Antes de escribir un loader propio conviene dominar los dos que Astro regala, porque cubren casi todo el contenido local y porque encarnan, en pequeño, el contrato que luego imitarás. glob y file resuelven dos formas distintas de guardar datos en disco: muchos ficheros, cada uno una entrada; o un solo fichero con una lista dentro. Entender cómo eligen los ficheros, cómo los parsean según su extensión y cómo bautizan cada entrada con un id es entender el grueso del trabajo cotidiano con la Content Layer.

🎯 Al terminar esta lección sabrás
  • Elegir entre glob y file según cómo esté dispuesto el contenido en disco.
  • Escribir patrones de rutas precisos con pattern, incluyendo exclusiones y varias extensiones.
  • Comprender cómo cada loader parsea el contenido según su extensión —Markdown frente a datos—.
  • Saber cómo se deriva el id de cada entrada y cuándo conviene forjarlo con generateId.

glob y file: dos disposiciones del contenido

glob es el loader de “muchos ficheros”. Recorre un directorio, selecciona los ficheros que casan con un patrón y produce una entrada por cada fichero. Es el loader natural de un blog, una documentación o un porfolio, donde cada pieza de contenido merece su propio Markdown. Se configura con dos opciones centrales, pattern y base, y una tercera opcional, generateId.

import { glob } from 'astro/loaders';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
  schema: z.object({ title: z.string(), pubDate: z.coerce.date() }),
});

file, en cambio, es el loader de “un solo fichero”. Toma un único documento de datos —un JSON o un YAML que contiene una lista o un mapa— y produce una entrada por cada elemento de esa lista. Es el formato idóneo para datos que se editan juntos y no merecen un fichero cada uno: autores, países, una tabla de precios, la configuración de unos redirects.

import { file } from 'astro/loaders';

const autores = defineCollection({
  loader: file('src/data/autores.json'),
  schema: z.object({ id: z.string(), nombre: z.string() }),
});

La elección entre ambos no es de gusto sino de disposición. Si cada unidad de contenido es larga, se edita por separado y quizá lleva cuerpo Markdown, glob le da a cada una su fichero. Si son muchos registros cortos que se revisan de un vistazo y cambian a la vez, file los mantiene juntos en un documento. Un mismo proyecto suele usar los dos: glob para los posts, file para la lista de autores a los que esos posts referencian.

📁

glob

Muchos ficheros, una entrada por cada uno. Para blogs, docs y porfolios donde cada pieza es un Markdown.

📄

file

Un fichero con una lista, una entrada por elemento. Para autores, paises o tablas que se editan juntas.

🧭

pattern

El patron glob que elige que ficheros entran, con exclusiones por ! y comodines para varias extensiones.

🏷️

id

La identidad de cada entrada: derivada de la ruta en glob, tomada de un campo o clave en file.

Patrones de rutas y parseo por extensión

El pattern de glob es un patrón glob resuelto contra base. **/*.md toma todos los Markdown a cualquier profundidad; *.md se limita al primer nivel; **/*.{md,mdx} admite dos extensiones; y un array combina reglas. La exclusión con ! es la herramienta para descartar borradores o parciales: ['**/*.md', '!**/_*.md'] incluye todo el Markdown salvo el que empieza por guion bajo.

loader: glob({
  pattern: ['**/*.md', '!**/_borradores/**'],
  base: './src/content/docs',
})

Lo que ocurre después de seleccionar un fichero depende de su extensión, y aquí está el matiz más importante de glob. Ante un .md o un .mdx, el loader separa el frontmatter del cuerpo: el frontmatter se convierte en el data de la entrada —el que valida tu esquema— y el cuerpo queda como body sin renderizar, listo para que render() lo transforme en HTML. Ante un .json o un .yaml, no hay cuerpo: todo el fichero es data, y la entrada no tendrá contenido renderizable.

Esa distinción tiene una consecuencia práctica que sorprende a quien la descubre tarde: solo las entradas con cuerpo —las que vienen de Markdown— pueden renderizarse con render(). Una entrada nacida de un JSON es datos puros; pedirle contenido renderizado no tiene sentido, porque nunca hubo un cuerpo que renderizar. El parseo por extensión decide, en definitiva, si una entrada es “un documento con texto” o “un registro de datos”.

ℹ️
glob no es solo para Markdown

Aunque su uso estrella es un blog de Markdown, glob también sirve para muchos ficheros de datos: pattern: '**/*.json' produce una entrada por cada JSON de una carpeta, cada uno un registro sin cuerpo. La regla es simple: glob cuenta ficheros —uno por entrada— y file cuenta elementos dentro de un fichero. La extensión solo decide si hay cuerpo, no cuántas entradas salen.

file parsea distinto porque su entrada es otra. Espera que el fichero contenga un array de objetos —y saca una entrada por objeto— o un mapa de objetos —y saca una entrada por par clave-valor—. Para formatos que no sabe leer de fábrica, como CSV o TOML, admite una opción parser: una función que recibe el texto crudo y devuelve el array o el mapa ya parseado. Así, el loader integrado se estira a fuentes que no son JSON ni YAML sin necesidad de escribir un loader entero.

loader: file('src/data/tabla.csv', {
  parser: (texto) => parseCsvAObjetos(texto),
})

Combinar varios loaders en un proyecto

Rara vez un sitio usa un solo loader. Un blog real declara varias colecciones a la vez —posts con glob, autores con file, quizá una tabla de etiquetas con otro file— y todas conviven en el mismo content.config.ts. Cada una elige el loader que su disposición pide, y el objeto collections las reúne bajo sus nombres, que son la identidad con la que luego las consultarás.

import { defineCollection, z } from 'astro:content';
import { glob, file } from 'astro/loaders';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
  schema: z.object({ title: z.string(), autor: z.string() }),
});

const autores = defineCollection({
  loader: file('src/data/autores.json'),
  schema: z.object({ id: z.string(), nombre: z.string() }),
});

export const collections = { blog, autores };

Que convivan no significa que se mezclen. Cada colección tiene su propio store y su propio espacio de id, de modo que un id repetido en dos colecciones distintas no colisiona: getEntry('blog', 'ana') y getEntry('autores', 'ana') apuntan a entradas diferentes. Esa separación por colección es lo que permite que el campo autor de un post —una cadena— haga de referencia al id de la colección autores, tejiendo relaciones entre conjuntos de datos sin que sus identidades se pisen.

El id de cada entrada

Cada entrada del store necesita un id único dentro de su colección, y cada loader lo deriva a su manera. glob lo saca de la ruta: toma el camino del fichero relativo a base, le quita la extensión y lo normaliza. Así blog/mi-post.md produce el id mi-post, y blog/2026/enero.md produce 2026/enero. Ese id es la llave estable con la que getEntry('blog', 'mi-post') localiza la entrada, y casi siempre coincide con el slug de su URL.

Cuando la ruta no es el id que quieres, glob acepta una función generateId que lo forja a tu gusto —a partir de un campo del frontmatter, por ejemplo—. Recibe la ruta y los datos ya parseados, y devuelve la cadena que quieras usar como identidad. Es la vía para que el id salga de un campo slug explícito en lugar del nombre del fichero.

loader: glob({
  pattern: '**/*.md',
  base: './src/content/blog',
  generateId: ({ entry, data }) => data.slug ?? entry,
})

file no deriva el id de una ruta, porque todas sus entradas comparten fichero. Cuando el documento es un array, cada objeto debe traer su propio campo id, y ese valor es la identidad de la entrada. Cuando es un mapa, la clave de cada par hace de id y los objetos no necesitan repetirlo. En ambos casos, el id está dentro de los datos, no en el sistema de ficheros.

Un detalle que conviene no olvidar: los id deben ser únicos dentro de la colección. Con glob esa unicidad viene casi regalada, porque las rutas ya son únicas en un sistema de ficheros; el riesgo aparece si tu generateId produce el mismo valor para dos ficheros distintos, en cuyo caso una entrada pisa a la otra sin ningún aviso. Con file, la responsabilidad es tuya: dos objetos con el mismo campo id, o dos claves iguales en el mapa, provocan la misma colisión silenciosa. Mantener un generateId inyectivo y unos identificadores sin repeticiones es lo que garantiza que ninguna entrada devore a otra.

flowchart TD
GLOB[glob loader] --> FILES[muchos ficheros]
FILES --> EXT{extension}
EXT -->|md o mdx| BODY[data mas body renderizable]
EXT -->|json o yaml| DATA[solo data]
FILES --> RUTA[id derivado de la ruta]
FILE[file loader] --> ONE[un fichero con lista]
ONE --> ELEM[una entrada por elemento]
ELEM --> CAMPO[id desde campo id o clave]
style BODY fill:#a6e3a1,color:#11111b
style DATA fill:#89b4fa,color:#11111b
style RUTA fill:#f9e2af,color:#11111b
style CAMPO fill:#f9e2af,color:#11111b

La estabilidad del id importa más de lo que parece, porque es la referencia con la que unas colecciones apuntan a otras. Si un post referencia a su autor por id, cambiar ese id —renombrando el fichero, alterando el campo— rompe la referencia. Por eso conviene tratar el id como un identificador de verdad: estable, deliberado, no un accidente del nombre que le pusiste al fichero un martes por la tarde.

💡
El id como slug, con cuidado

Que el id de glob coincida con el slug de la URL es cómodo, pero acopla dos cosas que a veces conviene separar: la identidad de la entrada y su dirección pública. Si prevés que las URLs cambien —por SEO, por idioma, por reestructuración— saca el slug a un campo del frontmatter y deriva la URL de ahí, dejando el id como identidad interna estable. Así puedes cambiar la dirección sin romper las referencias que usan el id.

Un id es una promesa de identidad en el tiempo

Detrás de la humilde cuestión de “cómo se llama cada entrada” se esconde uno de los problemas más subestimados del diseño de datos: la identidad estable. Un id no es una etiqueta cosmética, es una promesa de que esta entrada seguirá siendo señalable como la misma a lo largo del tiempo, aunque su contenido cambie, aunque se reordene, aunque migre de fuente. Toda referencia entre datos —un post que apunta a su autor, una página que enlaza a otra, una imagen atada a un producto— descansa sobre esa promesa, y cuando la promesa se rompe, se rompe en silencio: la referencia queda colgando, apuntando a un id que ya no existe. Por eso derivar el id de la ruta del fichero es a la vez cómodo y peligroso: cómodo porque no tienes que pensarlo, peligroso porque acopla la identidad de un dato a un detalle tan volátil como el nombre de su fichero. El día que reorganizas carpetas por estética estás, sin saberlo, cambiando la identidad de cada entrada que moviste. La disciplina que separa el código robusto del frágil es entender que la identidad merece decidirse, no heredarse por defecto: elegir conscientemente qué campo es el id —un slug estable, un identificador de la fuente, una clave de negocio— y protegerlo de los cambios cosméticos. generateId y el campo id de file existen precisamente para darte ese control. Usarlos bien es aceptar que nombrar las cosas de forma que sigan nombrando lo mismo mañana es, como decía el viejo chiste, uno de los dos problemas difíciles de la informática.

⚔️ Domina los dos loaders de fábrica
  1. Declara una colección docs con glob y un patrón que incluya .md y .mdx pero excluya los ficheros que empiezan por guion bajo.
  2. Declara una colección autores con file sobre un JSON que sea un array de objetos, cada uno con su campo id.
  3. Añade un JSON a la carpeta de docs y comprueba que glob lo trae como entrada sin cuerpo; intenta renderizarlo y observa por qué no procede.
  4. Usa generateId para que el id de un post salga de un campo slug del frontmatter en vez del nombre del fichero, y razona qué referencias protege eso.