Skip to main content
Back to the blog

2 min read

Bilingual content in Astro: one file per language, not one field per language

I started by storing title and summary as {es, en} objects in a single MDX. It worked for metadata but broke the body's SEO. The right physical structure was a different one.

  • astro
  • content-layer
  • i18n
  • seo

Astro’s Content Layer derives types from your Zod schemas, so the first version of my projects collection felt natural: one MDX per project, with the translatable fields as { es, en } objects.

title: z.record(z.enum(["es", "en"]), z.string());

For short metadata — title, tagline, summary — that’s reasonable. The problem shows up with the body. An MDX file has one body. If the post lives in a single file, the reader on /en ends up seeing the Spanish body, or you have to invent some mechanism to split the body in two. Either option is a wound in bilingual SEO: Google indexes an English page with mixed content.

The structure that actually scales

I fixed it by adopting a physical convention: one folder per slug, one file per language, monolingual frontmatter.

src/content/projects/checkout-revamp/es.mdx
src/content/projects/checkout-revamp/en.mdx

The glob loader picks up both. Each entry’s id ends up as checkout-revamp/es, and from there I derive slug and language with two trivial helpers:

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;
}

The frontmatter is now monolingual — it matches the file’s language — and the schema simplifies: title: z.string() instead of a record. Each locale gets its own body, written as if it were native, not translated word for word.

The gotcha that cost me half an hour: getStaticPaths runs sandboxed

When building the dynamic routes I assumed getStaticPaths could use helpers defined in the .astro frontmatter itself. It can’t. That function runs in an isolated sandbox during the build, before the page module is fully evaluated, so it sees nothing of the component’s scope.

The fix is to move the shared logic into an importable module — in my case src/lib/projects.ts — and have both getStaticPaths and the page body import it. Once the helpers live outside the frontmatter, both sides speak the same language.

What I took away

Your schema’s shape should follow your content’s shape, not the other way around. A bilingual post isn’t two fields inside one file: it’s two documents that share an identity. Modeling it as folder-per-slug gave me native bodies per language, correct hreflang, and a simpler schema. And the next time something isn’t defined inside getStaticPaths, I already know where to look.