Contenido traducido: colecciones por idioma y fallback
Organizar el contenido real de un sitio multilingüe más allá del enrutado. Dos estrategias para las colecciones por idioma, carpeta por locale o campo lang en el frontmatter, y cómo derivar el idioma del id con el glob loader de la Content Layer. La configuración fallback para que un idioma incompleto se apoye en otro, la diferencia entre fallbackType redirect y rewrite, y cómo detectar el idioma vigente al renderizar.
El enrutado i18n dibuja las direcciones, pero no llena las páginas: alguien tiene que escribir la traducción, guardarla y encontrarla. Ahí aparece la pregunta que decide la ergonomía de todo el proyecto multilingüe: cómo organizas el contenido traducido. ¿Una carpeta por idioma? ¿Un campo lang en cada archivo? ¿Qué pasa cuando el francés aún no tiene un artículo que el español sí tiene: un 404, o la versión española de socorro? Las colecciones de contenido y la opción fallback de la configuración responden a esas preguntas, y la respuesta correcta es la que evita mantener a mano la correspondencia entre las versiones de una misma página en distintos idiomas.
- Organizar colecciones por idioma con carpeta por
localeo con un campolangen el frontmatter. - Derivar el idioma del
idde una entrada con elglobloader de la Content Layer. - Configurar
fallbackpara que un idioma incompleto se apoye en otro. - Distinguir
fallbackTyperedirectderewritey detectar el idioma vigente al renderizar.
Dos estrategias para las colecciones por idioma
Hay dos maneras sensatas de guardar contenido en varios idiomas, y elegir bien ahorra fricción para siempre. La primera es la carpeta por idioma: dentro de la colección, una subcarpeta por cada locale, y dentro de cada una los mismos archivos con el mismo nombre. El idioma queda implícito en la ruta del fichero.
src/content/blog/
es/primer-post.md
es/segundo-post.md
en/primer-post.md
fr/primer-post.md
La segunda es el campo en el frontmatter: todos los archivos conviven en la misma carpeta y cada uno declara su idioma en un campo lang de su cabecera. La correspondencia entre versiones se establece con un slug o un translationId compartido. Es más plano y más explícito, a cambio de mezclar idiomas en un mismo directorio.
Con el glob loader de la Content Layer, la carpeta por idioma es especialmente cómoda, porque el idioma cae solo dentro del id de cada entrada. Al cargar con base en la raíz de la colección, un fichero en es/primer-post.md recibe el id es/primer-post, y de ahí extraer el idioma es partir una cadena.
// src/content.config.ts
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(),
translationId: z.string().optional(),
}),
});
export const collections = { blog };
Con esta colección, la página dinámica que genera las rutas puede leer el idioma directamente del id, sin necesidad siquiera de un campo lang, porque la propia estructura de carpetas lo codifica. El idioma deja de ser un dato que hay que recordar escribir y pasa a ser una consecuencia de dónde vive el archivo.
Generar las rutas y detectar el idioma vigente
Con el contenido organizado, la página que lo sirve extrae de cada id dos cosas: el idioma y el resto de la ruta. Una ruta dinámica como src/pages/[lang]/blog/[...slug].astro recorre la colección en getStaticPaths y reparte cada entrada en su URL localizada.
---
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => {
const [lang, ...resto] = post.id.split('/');
return {
params: { lang, slug: resto.join('/') },
props: { post },
};
});
}
const { post } = Astro.props;
const idioma = Astro.currentLocale;
---
<h1>{post.data.title}</h1>
Detectar el idioma vigente al renderizar tiene dos vías, y conviene saber cuándo usar cada una. Una es el param: Astro.params.lang te da el segmento tal cual apareció en la URL. La otra es Astro.currentLocale, que Astro calcula a partir de la URL confrontándola con tu lista de locales, y que es la vía recomendada porque respeta los locales con path y codes personalizados donde el segmento y el código no coinciden. Para filtrar el contenido de un idioma —mostrar solo los posts en francés en la portada francesa— comparas el idioma extraído del id con el currentLocale de la página.
fallback: apoyarse en otro idioma cuando falta la traducción
Traducir un sitio entero de golpe es un lujo raro; lo normal es que el inglés tenga cincuenta páginas y el francés solo veinte. La opción fallback de la configuración resuelve el hueco: declara, por cada idioma, en qué otro apoyarse cuando una página no existe en él. Así el visitante francés que pide una página aún no traducida no topa con un 404, sino con la versión de reserva.
// astro.config.mjs
i18n: {
locales: ['es', 'en', 'fr'],
defaultLocale: 'es',
fallback: {
fr: 'en',
en: 'es',
},
routing: {
prefixDefaultLocale: false,
fallbackType: 'redirect',
},
},
Aquí el francés cae al inglés y el inglés al español. Si /fr/contacto/ no existe pero /en/contacto/ sí, el visitante que pide la francesa recibe la inglesa. La cadena se resuelve por pasos: un idioma cae a su fallback, y ese fallback podría caer al suyo, hasta encontrar una página real o agotarse.
El matiz decisivo es fallbackType, que gobierna cómo se sirve esa reserva. Con redirect —el valor por defecto—, Astro emite una redirección: la URL en la barra cambia de /fr/contacto/ a /en/contacto/, y el usuario ve que está leyendo la versión inglesa. Con rewrite, en cambio, se sirve el contenido del fallback bajo la URL original: la barra sigue diciendo /fr/contacto/ aunque el HTML sea el inglés, sin salto visible.
Carpeta por idioma
Una subcarpeta por locale. El idioma queda implicito en el id. Comodo con el glob loader.
Campo lang
Todo en una carpeta y un campo lang por archivo. Mas plano, mezcla idiomas en un directorio.
fallbackType redirect
La reserva se sirve redirigiendo. La URL cambia al idioma que si tiene la pagina.
fallbackType rewrite
La reserva se sirve bajo la URL original. La barra no cambia aunque el idioma si.
flowchart TD REQ[peticion a fr contacto] --> EXIST[existe la pagina en fr] EXIST -->|si| SERVE[se sirve la version francesa] EXIST -->|no| FB[fallback dice fr cae a en] FB --> TYPE[fallbackType decide como] TYPE -->|redirect| RD[redirige a en contacto] TYPE -->|rewrite| RW[sirve en bajo la URL de fr] style REQ fill:#89b4fa,color:#11111b style TYPE fill:#f9e2af,color:#11111b style SERVE fill:#a6e3a1,color:#11111b
Para que fallback funcione, la página de reserva debe existir en la misma ruta dentro del otro idioma: /fr/contacto/ cae a /en/contacto/ solo si esta última existe. El mecanismo empareja por ruta, no por significado, así que mantener los mismos nombres de fichero entre idiomas —justo lo que facilita la carpeta por idioma— es lo que hace que el fallback encaje. Si traduces también los slugs, el emparejamiento automático deja de casar y tendrás que resolver la correspondencia tú.
fallbackType: 'rewrite' es cómodo porque evita el salto de URL, pero tiene una trampa de honestidad: el visitante cree estar en la versión francesa —lo dice la barra— cuando en realidad lee inglés. Para el usuario puede desorientar; para el SEO puede confundir a los buscadores sobre qué idioma vive en esa URL. Úsalo con cabeza: para páginas donde la reserva es aceptable de forma transparente, y avisando en la interfaz de que el contenido está en otro idioma. Cuando la distinción importa, redirect es más sincero.
El error de fondo en casi todo sitio multilingüe mal hecho es tratar la relación entre las versiones de una página como un dato primario: una tabla, un mapa, una lista de parejas —esta página española corresponde a esta inglesa— que alguien mantiene a mano y que empieza a mentir al primer descuido. La organización por carpeta con nombres compartidos propone lo contrario: que esa correspondencia no se declare, sino que emerja de la estructura. Si primer-post.md vive en es, en en y en fr, la relación entre las tres versiones no está escrita en ningún sitio y sin embargo es inequívoca, porque se lee de la propia disposición de los archivos. Esto es una aplicación de una idea honda: la mejor manera de mantener consistente una relación es no almacenarla, sino hacer que se derive de algo que ya tienes por otras razones. El fallback es la misma filosofía llevada al tiempo: en vez de mantener una lista de qué páginas faltan en cada idioma —lista que se desactualizaría a cada traducción nueva—, declaras una sola regla de precedencia entre idiomas y dejas que el sistema calcule, página a página, si hay que recurrir a la reserva. La consecuencia práctica es que tu sitio se vuelve antifrágil frente a las traducciones a medias: puedes publicar el francés al cincuenta por ciento sin romper nada y sin llevar la cuenta de qué falta, porque la cuenta la lleva la estructura. La lección que trasciende el i18n es que casi toda relación que sientes la tentación de mantener en una tabla —correspondencias, jerarquías, pertenencias— suele poder codificarse en una estructura que la haga evidente y automática. Cuando lo consigues, la clase entera de errores por desincronización simplemente desaparece.
- Crea una colección de blog con carpetas
es,enyfr, y deriva el idioma delidde cada entrada engetStaticPaths. - Filtra la portada de cada idioma comparando el idioma del
idconAstro.currentLocale. - Declara
fallback: { fr: 'en', en: 'es' }, borra una página del francés y comprueba que se sirve la inglesa. - Alterna
fallbackTypeentreredirectyrewritey observa la diferencia en la URL de la barra al pedir la página que falta.