Saltar al contenido principal
Volver al blogCopiar Markdown
·Actualizado ·11 min de lectura
·astrowebdevtypescriptmigration

Astro 6: content.config.ts, Zod 4 y la trampa de slug a id

Migré este sitio de Astro 5 a 6 en producción: ContentSchemaContainsSlugError, loaders de content.config.ts, defaults de Zod 4. Cómo arreglé cada uno. →

Óscar Gallego

Ingeniero de software · Málaga

Migración de Astro 5 a Astro 6: Content Layer y Server Islands
En este artículo

Respuesta corta: antes de subir a Astro 6, haz tres comprobaciones. grep -rn '\.slug' src: cada resultado pasa a ser .id. Asegúrate de que el schema está en src/content.config.ts y en ningún otro sitio. Y busca cualquier .default() que vaya después de un .transform(), porque en Zod 4 el default tiene que coincidir con el tipo ya transformado. Súmale Node 22.12 o superior y tienes cubiertos los cambios que muerden.

Actualicé este mismo sitio de Astro 5 a Astro 6. El plan era un pnpm up astro rápido, un vistazo al changelog y un café para celebrarlo.

Del café, nada.

Me pasé un buen rato mirando un ContentSchemaContainsSlugError que parecía un acertijo, y otro rato desenredando un default de Zod que compilaba perfecto en v5 y explotaba en v6.

La buena noticia antes de la batallita: v6 es una evolución de v5, no una reescritura. Mismo Content Layer, mismas Server Islands. Lo que cambia es que se acabaron los periodos de gracia. Las escotillas de emergencia que te dejaban tirar en v5 con código con pinta de v4 ya no están. Así que si tu migración a v5 quedó a medias, v6 es donde te pasan la factura.

Una nota sobre las fechas: Astro 6 salió estable el 10 de marzo de 2026, así que la migración que cuento es posterior a la fecha de publicación original de este post (diciembre de 2025). La parte del content.config.ts mal colocado sí es de noviembre de 2025, todavía en Astro 5.15.

Este post es la ruta que recorrí de verdad, de v5 a v6, con los breaking changes que me mordieron primero. Si vienes directo de Astro 4, salta a la sección del final: los cimientos de v5 siguen valiendo y los vas a necesitar antes.

Qué se rompe al pasar de 5 a 6 (versión de 60 segundos)

Todo lo de abajo sale de la guía oficial de upgrade a v6, contrastado con mi propio diff:

  • Node v22.12.0 es el nuevo mínimo, y solo se soportan versiones pares. La v23 no lo arranca.
  • src/content.config.ts es obligatorio si usas content collections. La ruta antigua src/content/config.ts ya no se lee.
  • El flag legacy.collections desaparece. Si te apoyabas en él en v5, te han quitado la muleta.
  • El identificador es post.id, no post.slug. La Content Layer ya movió el identificador a id en v5; v6 solo elimina el fallback de las colecciones legacy, así que no queda slug al que agarrarse. Un campo slug en un schema lanza ContentSchemaContainsSlugError.
  • Astro.glob() se elimina. Cámbialo por import.meta.glob() (que ya no devuelve una Promise) o por getCollection().
  • Zod va por la v4. Los valores por defecto tienen que coincidir con el tipo de salida tras los transforms, y los imports de astro:schema pasan a astro/zod.
  • getEntryBySlug() y getDataEntryById() quedan deprecados en favor de un único getEntry().

Si solo te quedas con una idea: el salto de v5 a v6 no va de juguetes nuevos. Va de borrar por fin los parches de compatibilidad que llevabas ignorando.

La trampa de slug a id (la que me costó una hora)

Aquí es donde v6 me pegó más fuerte, porque el mensaje de error apunta a tu schema, no al culpable real.

En Astro 4, cada entry tenía un slug y casi todos lo metíamos directo en getStaticPaths. La Content Layer de Astro 5 ya movió el identificador a id, pero la vía de las colecciones legacy mantenía el slug con vida, así que el código chapucero sobrevivía. Astro 6 elimina esa vía legacy. Ya no hay slug al que recurrir.

Este es el diff que arregló mi build:

---
// src/pages/blog/[...slug].astro
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    // Resto de Astro 5: post.slug ya no existe en v6
    // params: { slug: post.slug },
    params: { slug: post.id },
    props: post,
  }));
}
---

Si tu schema además declara un campo slug, eso es lo que dispara ContentSchemaContainsSlugError. Quítalo y deja que el loader glob derive el id del nombre del archivo. Haz un “Find in Files” de .slug antes de actualizar. Es la diferencia entre un arreglo rápido y una tarde entera.

Zod 4: el default que te miente

El segundo golpe. En Zod 3 (Astro 5), el valor por defecto coincidía con el tipo de entrada. En Zod 4 (Astro 6) tiene que coincidir con el tipo de salida, el que te queda después de que se ejecuten los transforms.

Así que esto, copiado de mi propio contador de visitas de la home, se rompió:

// Astro 5, Zod 3: el default es el string previo al transform
views: z.string().transform(Number).default("0"),

// Astro 6, Zod 4: el default tiene que ser el number posterior al transform
views: z.string().transform(Number).default(0),

Ya que estás ahí, arregla también los imports. astro:schema queda deprecado en v6:

// Antes
import { z } from "astro:schema";
// Después
import { z } from "astro/zod";

No necesitas obligatoriamente la ruta astro/zod. zod es dependencia directa en Astro 6, así que import { z } from "zod" a secas también funciona, y es lo que uso en producción. astro/zod solo reexporta la versión exacta de Zod contra la que Astro valida tus schemas, para que no acabes en una copia descuadrada.

El .nonempty() sobre strings también desaparece en Zod 4. Cámbialo por .min(1).

Clavar bien estos límites de tipos (el default coincidiendo con el tipo de salida, sin deriva silenciosa entre string y number) es la misma disciplina que aplico en todo el código; el resto lo junté en buenas prácticas en TypeScript.

Las APIs eliminadas, y qué usar en su lugar

Astro.glob() quedó deprecado ya en v5 y ahora se elimina del todo. El reemplazo depende de qué estuvieras globbeando:

// ¿Consultas content collections? Usa el Content Layer.
const posts = await getCollection('blog');

// ¿Globbeas ficheros sueltos? import.meta.glob, y ojo:
// ya no devuelve una Promise, así que quita el await.
const modules = import.meta.glob('./data/*.json', { eager: true });

Para buscar entries de una colección, getEntryBySlug() y getDataEntryById() colapsan en una sola función:

// Antes
const post = await getEntryBySlug('blog', 'my-post');
// Después
const post = await getEntry('blog', 'my-post');

Y la limpieza que tardó diez segundos pero sentó mejor que ninguna: borrar el bloque legacy de astro.config.mjs.

import { defineConfig } from 'astro/config';

export default defineConfig({
  // Borra el bloque entero. En v6 no hace nada salvo recordarte
  // que pospusiste la migración de verdad.
  legacy: {
    collections: true,
  },
});

¿Faltan campos del schema? Mira dónde está content.config.ts

En Astro 5 esto fallaba en silencio, que es la peor forma de fallar. Añadí un campo relatedSlug al schema del blog, lo puse en el frontmatter y nunca apareció: ni en los tipos ni en post.data. Hacerlo obligatorio no lanzó ningún error. Renombrarlo tampoco sirvió. Cuando hasta un campo obligatorio se ignora, Astro no está leyendo tu config.

La causa era una barra. En noviembre de 2025, todavía en Astro 5.15, estaba editando src/content/config.ts mientras había un src/content.config.ts viejo en la raíz de src/, y Astro leía ese. v6 es más amable: si queda un src/content/config.ts, salta LegacyContentConfigError en vez de ignorarlo. Para confirmar qué fichero carga Astro de verdad, mira los tipos generados:

// .astro/content.d.ts
export type ContentConfig = typeof import("../src/content.config.js");

Repasa esto antes de echarle la culpa al framework:

  1. Ubicación. src/content.config.ts, en la raíz de src/, no dentro de src/content/.
  2. Nombre. Exactamente content.config.ts. Cualquier variación se ignora.
  3. Sin duplicados. find src -path '*content*' -name 'config.*' solo debería encontrar un fichero. Pasa el schema al bueno y borra el otro.
  4. Exports. El fichero tiene que hacer export const collections.
  5. Caché. Borra .astro/ y reinicia el servidor de desarrollo para que los tipos se regeneren desde el fichero correcto.

content.config.ts: el loader glob() y defineCollection

Nada de lo anterior es la razón para actualizar. La razón es lo que v5 introdujo y v6 convierte en el único camino: el Content Layer es un pipeline de datos de verdad, no un simple lector de Markdown. Todo vive en content.config.ts, donde cada colección es un defineCollection con su loader. Para Markdown local ese loader es glob() (lo ves en la sección de v4 más abajo); la potencia está en que puedes cambiarlo por uno que escribas tú.

El loader glob que montas en content.config.ts es solo una función que devuelve un array de objetos. Lo cual significa que puedes escribir el tuyo. Este es el loader que uso para traer mis estrellas de GitHub a la sección de proyectos, sin un fetch() pudriéndose en el top-level de un componente:

// src/content.config.ts
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';

const githubLoader = {
  name: 'github-repos',
  load: async ({ store, logger }) => {
    logger.info('Fetching repos from GitHub...');
    const response = await fetch('https://api.github.com/users/garbarok/repos');
    const repos = await response.json();
    for (const repo of repos) {
      store.set({
        id: repo.name,
        data: {
          description: repo.description,
          stars: repo.stargazers_count,
          url: repo.html_url,
        },
      });
    }
  },
};

const projects = defineCollection({
  loader: githubLoader,
  schema: z.object({
    stars: z.coerce.number(),
    url: z.string().url(),
  }),
});

export const collections = { projects };

En mis componentes solo llamo a getCollection('projects'). Al frontend le da igual de dónde vino el dato, y Astro lo cachea en tiempo de build. Esa separación es lo que ganas con la migración.

Las Server Islands (server:defer) son la otra idea de v5 que pasa a v6 tal cual. Un shell estático en el CDN con un hueco dinámico que se rellena con un fetch minúsculo por isla. Sin hidratación, sin React en el cliente. Si te la saltaste en v5, v6 es buena excusa para usarla por fin.

La parte honesta: dónde v6 da rabia

Te prometí una batallita, no un folleto comercial.

El mensaje ContentSchemaContainsSlugError es malo de verdad. Culpa a tu schema cuando el problema real suele ser un params: { slug: post.slug } tres ficheros más allá. Perdí tiempo persiguiendo la línea equivocada.

El cambio del default de Zod 4 es de los que compilan, pasan un smoke test rápido y luego sirven datos mal en producción, porque "0" y 0 parecen iguales hasta que algo más abajo hace cuentas. Testea tus defaults, no solo tus tipos.

Y lo de import.meta.glob() que “ya no devuelve una Promise” se pasa por alto fácil, porque quitar un await de código que funcionaba da cosa. No va a fallar con estruendo. Solo te va a dar una Promise donde esperabas datos.

Ninguno es un dealbreaker. Pero quien te venda v6 como un bump sin fricción no ha migrado un proyecto de verdad. (Astro 7 fue lo contrario: un pnpm up y un café.)

¿Sigues en Astro 4? La historia de v4 a v5

Todo lo de arriba asume que ya diste el salto de v4 a v5. Si no, esta es la parte que todavía importa, porque el Content Layer nació aquí.

En Astro 4, definías las collections con un string mágico:

// Astro 4, la forma antigua
import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content', // "busca ficheros, no sé cómo"
  schema: z.object({ /* ... */ }),
});

Astro 5 dejó de creer en la magia y pidió loaders explícitos:

// Astro 5+, la nueva forma
import { 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(),
  }),
});

Dos trampas más de la era v4 que v6 da por hecho que ya has arreglado:

El modo output: 'hybrid' desaparece. Astro es ahora estático por defecto con SSR opt-in, o server por defecto con estático opt-in. Borra hybrid de tu config.

Y post.render() ya no existe. Las content entries son objetos planos, así que importas el render:

import { render } from 'astro:content';
const { Content } = await render(post);

Haz ese trabajo, consigue un build verde en v5, y el paso a v6 del principio de este post se convierte en una tarde tranquila en vez de un infierno.

Lectura relacionada: si vas a hacer este refactor multi-archivo a cuatro manos con un asistente de IA, mi reseña de Google Antigravity cuenta cómo se portan los IDEs agénticos que probé en noviembre de 2025, durante el trabajo en Astro 5 que cuento arriba.

Lectura relacionada: si primero quieres el panorama de Astro 6 (qué es, islas y por qué carga tan poco JavaScript) antes de migrar, empieza por la introducción a Astro 6.

Fuentes


P.D. ¿Ya diste el paso a v6? ¿El cambio de slug a id te rompió el corazón dos veces, una en v5 y otra en v6? Cuéntamelo en LinkedIn.

Preguntas frecuentes

¿Tengo que pasar por v5 para llegar a v6?
En la práctica, sí. Las guías de upgrade son secuenciales, y v6 asume que tus colecciones ya usan el Content Layer. Si estás en v4, sigue los pasos de "¿Sigues en Astro 4?" más arriba, consigue un build verde en v5 y entonces da el paso a v6.
¿Puedo aguantar un poco más con el flag de legacy collections?
No. legacy.collections está eliminado, y si queda un src/content/config.ts salta LegacyContentConfigError. v6 sí ofrece legacy.collectionsBackwardsCompat como ayuda temporal para migrar, pero cuenta con pasar a src/content.config.ts y quitarlo.
¿Puedo usar Server Islands en Vercel o Netlify?
Sí, con el adapter correspondiente instalado. La isla es una serverless function que devuelve HTML, así que necesitas un host que ejecute functions. Nada de eso ha cambiado en v6.

Última actualización:

Comparte este artículo

Artículos relacionados