Saltar al contenido principal
Volver al blog

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.