Skip to main content
Volver al blog Copiar Markdown
· 10 min de lectura ·
astro webdev typescript migration

Guía de Migración a Astro 6 (2026) desde Astro 5

Actualicé este sitio de Astro 5 a Astro 6. Breaking changes que me mordieron: content.config.ts, la trampa slug a id, defaults de Zod 4, APIs eliminadas. →

Óscar Gallego

Óscar Gallego

Desarrollador Web

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

El fin de semana pasado 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é la primera hora mirando un ContentSchemaContainsSlugError que parecía un acertijo, y otros cuarenta minutos 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.

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. En un proyecto de 50 archivos es la diferencia entre un arreglo de cinco minutos 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).

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,
  },
});

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.

Las preguntas que me ibas a hacer igualmente

¿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, haz primero el trabajo de v5 (abajo), confirma un build verde y entonces da el paso a v6.

¿Puedo aguantar un poco más con el flag de legacy collections? No. Está eliminado, no deprecado. v6 no leerá src/content/config.ts ni respetará legacy.collections. Planifica la migración antes de subir la versión, no después.

¿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 cambió en v6.

¿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: la trampa más rastrera de toda esta saga se ganó su propio post. Cómo un content.config.ts mal colocado se comió mis campos de schema en silencio, y el arreglo de cinco minutos. 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é durante esta actualización.

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 Twitter/X.

Última actualización:

Comparte este artículo

Artículos relacionados