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. In how I model content, components and metadata with TypeScript, I go into what that contract can catch and what still needs review outside the type system.
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:
<div lang="es">
## Spanish copy goes here
...
</div>
<div lang="en">
## The English version
...
</div>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 without reusing its slug. The file ID keeps the Spanish URL, while slug_en defines a natural English one: /blog/astro-mdx-contenido-bilingue/ and /en/blog/astro-mdx-bilingual-content/. 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/. Those relationships live in src/config/i18n.mjs. Posts and projects are dynamic, so src/lib/routes.ts builds their paths from the collection data.
The helper receives an entry and a locale, then returns the matching public path:
export const getPostPath = (post, locale) =>
locale === 'en'
? `/en/blog/${post.data.slug_en}/`
: `/blog/${post.id}/`;It is not a dramatic abstraction, but it removes repeated work from places where a mistake becomes visible. The dynamic route passes the exact alternate to the language switcher and hreflang tags, while the sitemap uses the same helper. The frontmatter holds the relationship, so 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.