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.