Dos errores de i18n en Astro que no aparecen hasta comparar el HTML renderizado
imperfectsystems.com está sumando un locale en español, deliberadamente como piloto: tres posts aquí contra ciento sesenta y seis en vdaluz.com, así que cualquier error de i18n se descubre primero en el sitio chico. Agregar i18n: { defaultLocale: 'en', locales: ['en', 'es'], routing: { fallbackType: 'rewrite' } } a la configuración de Astro y reconstruir fue la parte fácil. Generó automáticamente versiones /es/ funcionales de la portada, la página 404, cada página estática, cero código de ruta extra. Desconfié de que fuera tan fácil. Tenía razón en desconfiar.
El fallback no sabe qué es una traducción
Los posts del blog viven en una content collection, src/content/blog/en/hello-dev-log.md y así, con traducciones al español llegando después a es/ a través de un pipeline separado. Mi ruta en inglés filtra la colección a solo entradas en/ y llama a getStaticPaths(). Asumí que el fallback de Astro haría el mismo truco que hizo con la portada: pedir /es/blog/hello-dev-log, recibir el contenido en inglés de vuelta, listo.
Y así fue, pero el mecanismo me sorprendió. La reescritura automática de fallback funciona a nivel de URL: toma la solicitud, quita el prefijo de locale, y vuelve a resolver la ruta que corresponda al path desnudo. Para una página estática eso es exactamente correcto. Para una ruta con getStaticPaths() leyendo una colección dividida por locale, significa que /es/blog/<slug> siempre va a renderizar lo que sea que produjo la ruta en inglés, para siempre, incluso después de que exista una traducción real al español en es/<slug>.md. El fallback no tiene idea de que una traducción es una cosa. Solo sabe “no coincidió ninguna ruta, probar con la ruta del locale por defecto en su lugar.”
Así que construí un /es/blog/[...slug].astro explícito que hace su propia búsqueda: prefiere la entrada es/, si todavía no existe recurre a en/. Lo mismo para el índice paginado. Son quizás cuarenta líneas extra, pero es la única forma de que una traducción futura realmente aparezca en vez de quedar tapada en silencio por un fallback de ruta que ya “resolvió” la URL.
const byLocale = (loc) =>
new Map(all.filter((p) => p.id.startsWith(`${loc}/`)).map((p) => [stripPrefix(p.id), p]));
const localized = byLocale(locale);
const fallback = byLocale('en');
const slugs = new Set([...fallback.keys(), ...localized.keys()]);
return [...slugs].map((slug) => localized.get(slug) ?? fallback.get(slug));
Nota al margen que costó un ciclo de debugging: los ids de la content collection también llevan el prefijo de locale (en/hello-dev-log), y un componente de card compartido estaba construyendo URLs de posts directamente desde post.id. El primer build produjo /blog/en/hello-dev-log. Quito el prefijo antes de pasar las entradas a cualquier cosa que construya una URL, pero conservo la entrada original con prefijo para la llamada a render() de Astro, ya que esa búsqueda usa el id real como clave y un clon sin prefijo no resuelve.
La etiqueta canonical que miente sobre dónde vive
Este no apareció en el navegador para nada. El sitio se veía completamente normal en ambos idiomas. astro check pasaba. El build era exitoso. Solo lo detecté porque había anotado “las páginas es/ se auto-canonicalizan” como un requisito explícito y decidí verificarlo de verdad en vez de confiar en que la página se viera bien:
grep -o '<link rel="canonical"[^>]*>' dist/client/es/index.html dist/client/index.html
Ambos archivos apuntaban a la misma URL. La en inglés.
El error: en una página servida a través de la reescritura automática de fallback, la portada bajo /es/, por ejemplo, Astro.url.pathname refleja la ruta que en realidad se ejecutó, que es la ruta de la portada en inglés, no la URL que pidió el navegador. Astro.currentLocale lo hace bien, dice correctamente 'es' en el mismo render exacto. Solo Astro.url miente, y solo en páginas renderizadas por fallback, las rutas de blog que había construido explícitamente no tenían este problema, porque ahí el path solicitado y la ruta que coincidió son la misma cosa.
Construir la etiqueta canonical con new URL(Astro.url.pathname, Astro.site), que es lo que el código ya hacía antes de que lo tocara, significaba que cada página en español renderizada por fallback se auto-canonicalizaba de vuelta al inglés. Google habría tenido dos URLs, ambas insistiendo en que la inglesa era la real. No estaba roto, solo mal en silencio, y mal en el único lugar donde nadie mira porque la página renderizada no da ninguna señal visual de que algo esté mal.
La corrección evita Astro.url por completo. getAbsoluteLocaleUrl(locale, barePath) de astro:i18n reconstruye la URL a partir del locale que Astro realmente resolvió más un path desnudo sin prefijo, y no le importa si la página actual llegó ahí a través de una ruta explícita o de una reescritura de fallback:
const bareLocalePath = Astro.url.pathname.replace(/^\/es(\/|$)/, '/') || '/';
const canonicalURL = getAbsoluteLocaleUrl(locale, bareLocalePath);
Lo que le diría a mi yo pasado
“Simplemente funcionó” en el primer build fue la señal de alarma, no el alivio. El fallback de i18n de Astro está bien diseñado para el caso para el que se construyó, páginas estáticas, sin variación de contenido por URL, y deja de ser silenciosamente la herramienta correcta en el momento en que una ruta tiene su propia idea de qué contenido pertenece a una URL. El error del canonical es la lección más filosa: un chequeo que solo mira si la página renderiza nunca va a atrapar un campo de metadata apuntando a la URL equivocada. Solo lo encontré al tratar “las páginas es/ se auto-canonicalizan” como algo para buscar con grep, no algo para revisar a simple vista.
Lecturas relacionadas
Agregar un tercer idioma rompió cosas que el build nunca podría haber atrapado
Ternarios binarios, un type guard con dos valores fijos, y una etiqueta hreflang en cada página que promete una traducción al portugués que da 404.
Corrigiendo cuatro pequeñas mentiras en la página de Deep Cut Atlas
Un guion que no coincidía con la tipografía propia de la app, un eslogan que no se entendía, una afirmación de función que la app no puede respaldar, y una barra final faltante en los datos estructurados.
Traducir una página de marketing sin duplicarla
La página en español existía, tenía una URL, aparecía en hreflang, y cada palabra estaba en inglés. La solución que casi se implementó era 130 líneas duplicadas esperando a desalinearse.