2 min de lectura
Contenido bilingüe en Astro: un archivo por idioma, no un campo por idioma
Empecé guardando título y resumen como objetos {es, en} en un solo MDX. Funcionaba para metadatos, pero rompía el SEO del cuerpo. La estructura física correcta era otra.
- astro
- content-layer
- i18n
- seo
El Content Layer de Astro deriva tipos de tus schemas Zod, así que la primera
versión de mi colección de proyectos parecía natural: un MDX por proyecto, con
los campos traducibles como objetos { es, en }.
title: z.record(z.enum(["es", "en"]), z.string());
Para metadatos cortos —título, tagline, resumen— eso es razonable. El problema
aparece con el cuerpo. Un MDX tiene un solo cuerpo. Si el post vive en un
archivo, el lector de /en termina viendo el cuerpo en español, o tienes que
inventar un mecanismo para partir el cuerpo en dos. Cualquiera de las dos
opciones es una herida en el SEO bilingüe: Google indexa una página inglesa
con contenido mezclado.
La estructura que sí escala
La corregí adoptando una convención física: una carpeta por slug, un archivo por idioma, frontmatter monolingüe.
src/content/projects/checkout-revamp/es.mdx
src/content/projects/checkout-revamp/en.mdx
El glob loader carga ambos. El id de cada entrada queda como
checkout-revamp/es, y de ahí derivo slug e idioma con dos helpers triviales:
export function entryLang(entry: { id: string }) {
const last = entry.id.split("/").at(-1);
return last === "es" || last === "en" ? last : null;
}
export function entrySlug(entry: { id: string }) {
return entry.id.split("/")[0] ?? entry.id;
}
El frontmatter ahora es monolingüe —corresponde al idioma del archivo— y el
schema se simplifica: title: z.string() en vez de un record. Cada locale
tiene su propio cuerpo, escrito como si fuera nativo, no traducido palabra por
palabra.
El gotcha que me costó media hora: getStaticPaths corre aislado
Al construir las rutas dinámicas asumí que getStaticPaths podía usar helpers
definidos en el frontmatter del propio .astro. No puede. Esa función corre en
un sandbox aislado durante el build, antes de que el módulo de la página se
evalúe del todo, así que no ve nada del scope del componente.
La solución es mover la lógica compartida a un módulo importable —en mi caso
src/lib/projects.ts— y que tanto getStaticPaths como el cuerpo de la página
lo importen. Una vez que los helpers viven fuera del frontmatter, los dos lados
hablan el mismo idioma.
Lo que me llevo
La forma de tu schema debería seguir la forma de tu contenido, no al revés. Un
post bilingüe no son dos campos dentro de un archivo: son dos documentos que
comparten identidad. Modelarlo como carpeta-por-slug me dio cuerpos nativos por
idioma, hreflang correctos y un schema más simple. Y la próxima vez que algo
no esté definido dentro de getStaticPaths, ya sé dónde mirar.