No son dos versiones idénticas pegadas una debajo de otra. El inglés necesita otro ritmo, ejemplos y expresiones propias. A veces una frase que funciona en español no merece sobrevivir a la traducción. Mantenerlas juntas sí ayuda a no perder el hilo cuando actualizo un hecho, un enlace o un ejemplo técnico.
Las rutas de artículo renderizan la misma entrada: /blog/slug/ en español y /en/blog/slug/ en inglés. Cada ruta selecciona su título, resumen y datos estructurados. Al terminar el build, la integración strip-localized-content de astro.config.mjs elimina del HTML el bloque cuyo idioma no corresponde a la ruta. El archivo publicado contiene solo el cuerpo del idioma solicitado.
El compromiso está en el mantenimiento: los dos cuerpos siguen compartiendo archivo MDX y la integración de build depende del formato de sus bloques div con atributo lang. El CSS conserva una regla para ocultar el otro idioma durante desarrollo, pero en producción ese bloque ya se ha eliminado. Si añadiera más idiomas o una estructura más compleja, preferiría archivos separados por locale para evitar ese procesamiento posterior.
Las rutas tienen una única fuente de verdad
Duplicar páginas no es el problema. Duplicar reglas sí lo es.
Hay rutas estáticas que no comparten nombre entre idiomas, como /sobre-mi/ y /en/about/. En cambio, los artículos conservan el slug y cambian de prefijo. En lugar de repartir esa lógica entre el selector de idioma, el componente SEO y el sitemap, la centralicé en src/config/i18n.mjs.
El helper recibe la URL actual y devuelve el par de rutas equivalente:
getLocalePaths('/blog/astro-mdx-contenido-bilingue/')
// {
// es: '/blog/astro-mdx-contenido-bilingue/',
// en: '/en/blog/astro-mdx-contenido-bilingue/'
// }No es una abstracción espectacular, pero quita trabajo repetido de los sitios donde un error tendría consecuencias visibles. El selector de idioma, las etiquetas hreflang y el sitemap parten de la misma relación. Cuando añado un artículo, no tengo que recordar tres mapas distintos de URLs.
Astro también permite configurar su propio enrutado i18n. En este proyecto mantengo el mapa manual porque necesito nombres de rutas diferentes entre español e inglés y ya tenía una estructura de páginas estáticas clara. La documentación de Astro explica las dos piezas, el enrutado localizado y las colecciones, pero no obliga a encajar todos los proyectos en la misma forma de organizarse.
El índice revela una consecuencia de guardar ambos idiomas juntos
La eliminación del otro idioma al terminar el build no resuelve el índice. render() devuelve todos los encabezados del MDX antes de ese paso. Si usara esa lista sin filtrarla, el índice lateral de un artículo en español enseñaría también los títulos en inglés.
Por eso el proyecto cuenta los encabezados de cada bloque y entrega al índice únicamente los que pertenecen a la ruta actual. Es una función pequeña, pero representa bien el tipo de mantenimiento que introduce esta estructura. Cuando eliges almacenar dos versiones en una entrada, debes revisar todas las partes que leen el contenido, no solo el texto que se ve en pantalla.
Los bloques deben mantenerse consecutivos y con el mismo formato porque esa función depende de ello. No es una regla que impondría a cualquier equipo, pero sí una convención que conviene documentar mientras siga usando este enfoque.
El idioma también llega a los buscadores
Dos rutas útiles para una persona pueden ser confusas para un buscador si no quedan relacionadas. Cada página genera su canonical y declara las alternativas española e inglesa con hreflang. El sitemap repite esa relación para las páginas estáticas, los proyectos y los artículos.
He detallado esa parte en cómo implementé hreflang en una web bilingüe. Aquí lo relevante es que los metadatos no se añaden al final como una tarea de SEO. Salen del mismo mapa de rutas que usa la navegación. Es más difícil que la versión inglesa apunte a una URL equivocada cuando no hay que escribir esa URL a mano en cada página.
La estructura también conecta con el trabajo de SEO técnico del portfolio. Una canonical correcta o un sitemap actualizado sirven de poco si el contenido, las rutas y los alternates se mantienen por separado y terminan contradiciéndose.
Cuándo dejaría de usar esta estructura
No elegiría un único MDX bilingüe para cualquier sitio. Lo cambiaría si ocurriera alguna de estas cosas:
- Un traductor o un equipo editorial necesitara trabajar sin tocar el texto del otro idioma.
- Cada mercado tuviera artículos, ejemplos o calendarios de publicación distintos.
- Añadiera más idiomas y el archivo dejara de ser fácil de revisar.
- La estructura del contenido dejara de encajar con el procesamiento posterior del build.
- Necesitara estados de traducción, revisiones o previsualizaciones propias de un CMS.
En ese contexto, dos colecciones por idioma o un CMS con entradas relacionadas darían más independencia. A cambio, habría que resolver la relación entre versiones, los slugs compartidos y los metadatos con el mismo cuidado.
Para este portfolio no buscaba demostrar que una arquitectura es “la correcta”. Buscaba que publicar un artículo en ambos idiomas fuera un proceso repetible, que no obligara a copiar componentes y que siguiera siendo comprensible dentro de unos meses. De momento, un MDX por pieza y unas pocas reglas explícitas cumplen mejor ese trabajo que una estructura más grande.
A site can look bilingual while the navigation is the only thing that has actually been translated. Articles, projects, their URLs, titles and metadata are where the extra work starts. If every part of the site handles language differently, publishing a new post becomes a set of manual steps waiting to be missed.
For this portfolio, each post lives in one MDX file with a Spanish and an English version. Astro builds two routes from that entry. I did not choose it as a universal i18n pattern. It suits a personal site that I write, maintain and publish myself.
It keeps several things simple, but it also comes with a constraint worth being honest about.
The content model comes before the page
An article is more than its body copy. It has a title, summary, publication date, tags and URL. Titles and summaries also need to be localised because they appear in post lists, search results and shared links.
The posts collection therefore keeps the shared fields and adds English versions for the text used outside the body:
const posts = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
title_en: z.string().optional(),
summary: z.string(),
summary_en: z.string().optional(),
tags: z.array(z.string()).default([]),
publishedAt: z.coerce.date(),
}),
});This does not translate content for me. It makes the data each entry needs explicit. If I forget a date or use the wrong data type, the problem appears while developing or building the site instead of after a broken page goes live. Astro’s Content Collections are useful here because the posts share a shape that can be validated.
On a site with a larger editorial team, I might keep languages separate from the start. In this project, having both titles in one frontmatter block makes it easier to publish them together and spot an English route that would otherwise inherit Spanish metadata.
One post file, two reading blocks
The body follows the same idea. Each MDX file holds two consecutive blocks:
## The English version
...
They are not meant to be word-for-word copies. English needs its own pace, examples and terminology. Sometimes a sentence that reads well in Spanish is not worth keeping in translation. Keeping the versions together does make it easier to update the same technical fact, link or example in both places.
The article routes render the same entry: /blog/slug/ in Spanish and /en/blog/slug/ in English. Each route selects its own title, summary and structured data. After the build, the strip-localized-content integration in astro.config.mjs removes the body block whose language does not match the route. The published HTML contains only the requested language.
The trade-off is maintenance: both bodies still share one MDX file, and the build integration relies on the format of their div blocks with a lang attribute. CSS still hides the other language during development, but that block has already been removed in production. With more languages or a more complex structure, I would use separate files per locale to avoid that post-processing step.
One place for route relationships
Duplicating pages is not necessarily bad. Duplicating the rules that connect them usually is.
Some static routes have different names, such as /sobre-mi/ and /en/about/. Blog posts keep their slug and change their prefix. Rather than scattering that logic across the language switcher, SEO component and sitemap, I keep it in src/config/i18n.mjs.
The helper receives the current URL and returns the matching pair:
getLocalePaths('/blog/astro-mdx-contenido-bilingue/')
// {
// es: '/blog/astro-mdx-contenido-bilingue/',
// en: '/en/blog/astro-mdx-contenido-bilingue/'
// }It is not a dramatic abstraction, but it removes repeated work from places where a mistake becomes visible. The language switcher, hreflang tags and sitemap start from the same route relationship. Publishing a post does not mean updating three different URL maps.
Astro also has built-in i18n routing configuration. I keep the mapping manual here because the two languages use different static route names and the site already had a clear static-page structure. Astro’s documentation covers both localised routing and collections, but neither requires every project to organise content in exactly the same way.
The table of contents exposes one consequence
Removing the other language after the build does not solve the table of contents. render() returns every heading in the MDX file before that step. Passing the list straight to the table of contents would make a Spanish page show English headings too.
The project therefore counts headings in each language block and passes the table of contents only the ones for the active route. It is a small function, but it represents the kind of maintenance this structure introduces. Once two versions live in one entry, every feature that reads the content needs to be checked, not just the text on screen.
That is why the language blocks need to stay consecutive and follow the same format. The heading helper relies on it. I would not impose that convention on every team, but it is worth documenting while this is the model I use.
Language reaches search engines too
Two useful routes for a person can still be ambiguous to a search engine if their relationship is missing. Every page builds its own canonical and declares Spanish and English alternatives with hreflang. The sitemap repeats that relationship for static pages, projects and posts.
I covered that implementation in more detail in my hreflang setup for a bilingual site. The relevant point here is that metadata is not an SEO task bolted on at the end. It comes from the same route map used by navigation. An English page is less likely to point to the wrong URL when that URL is not handwritten in every page.
It also fits into the technical SEO work on this portfolio. A correct canonical or current sitemap does not help much if content, routes and alternates are maintained independently and eventually contradict one another.
When I would move away from this model
I would not use a single bilingual MDX file for every website. I would change the approach if:
- A translator or editorial team needed to work without touching the other language.
- Each market had different articles, examples or publishing schedules.
- More languages made the file difficult to review.
- The content structure no longer suited the build post-processing step.
- I needed translation states, review steps or CMS previews.
At that point, separate collections per locale or a CMS with related entries would give each version more independence. That would still leave the need to model the relationship between versions, shared slugs and metadata carefully.
This portfolio does not need to prove that one architecture is “the right one”. It needs a repeatable way to publish in both languages without copying page components, and a structure I can still understand months later. For now, one MDX entry per piece and a few explicit rules do that job better than a larger system.