Blog

Adding a Russian locale to an Astro blog, no CMS

7 min read

In the Nuxt-to-Astro rewrite post, I mentioned that Astro's built-in i18n is a routing primitive, not a framework. That sentence is doing a lot of work. A routing primitive means: Astro knows your locales exist and will emit the right URL prefixes, but it gives you nothing else. No t() function, no typed translation files, no language-switcher helper, no SEO <link rel="alternate"> tags. Everything past the URL prefix is yours to build. For a marketing page or a component-driven UI, that gap is filled by a translation dictionary and a handful of utility functions. For a blog with MDX post bodies, the gap is different - the bodies themselves are the content, and they are already locale-specific by nature. What you need instead is a file-pairing convention: one file per locale, the same slug, and a function that can find the counterpart.

This post describes how that works on this site. The post itself is the proof: it has an EN file and a RU file sharing the same slug, and the language switcher in the header navigates between them. If you are reading this in English and want to verify the claim, hit the switcher. You will land on the Russian version of this exact page.

Route duplication, not runtime switching

The first decision is the shape of the URL tree. Astro's i18n config has a prefixDefaultLocale flag. With it off, English lives at / and /blog/, and Russian lives at /ru/ and /ru/blog/. The pages are duplicated on disk - src/pages/blog/index.astro and src/pages/ru/blog/index.astro are separate files - and at build time Astro emits separate static HTML for each. There is no runtime locale detection, no cookie, no redirect. A request for /ru/blog/ gets the RU blog index; a request for /blog/ gets the EN one. The locale is in the URL, full stop.

This is what "route duplication" means in practice. It sounds expensive but for a personal blog with a handful of posts it is not: you duplicate the page shell (the index, the tag pages, the layout) and the posts themselves are already locale-specific files. The dynamic per-post routes (src/pages/blog/[slug].astro and src/pages/ru/blog/[slug].astro) each query the content collection filtered by lang, so they only see their own locale's posts. Nothing is shared at the route layer except the components and the utility functions.

The big advantage over a single-locale-parameter approach is that every URL is a static HTML file. There is no client-side locale switching, no hydration cost, no redirect chain. A Russian reader bookmarks /ru/blog/adding-russian-locale-astro-blog/ and gets that file from a CDN edge, no JavaScript involved. That is the bet Astro is making for static sites, and for a blog it is the right bet.

One slug, two files, two langs

The content collection schema has two fields that make the pairing work: lang (an enum, en or ru, defaulting to en) and slug (a plain string, required). The schema is defined in src/content.config.ts:

schema: z.object({
  title: z.string(),
  description: z.string(),
  pubDate: z.coerce.date(),
  updatedDate: z.coerce.date().optional(),
  tags: z.array(z.string()).default([]),
  draft: z.boolean().default(false),
  lang: z.enum(["en", "ru"]).default("en"),
  slug: z.string(),
})

The glob loader in that same file keys collection entries by filename. That means two files in src/content/posts/ with different names are two different entries - even if they share a slug in their frontmatter. And the URL a post gets rendered at is derived from slug, not from the filename. So the convention for a bilingual pair is:

  • src/content/posts/adding-russian-locale-astro-blog.mdx - the EN file, lang: en, slug: adding-russian-locale-astro-blog
  • src/content/posts/adding-russian-locale-astro-blog-ru.mdx - the RU file, lang: ru, slug: adding-russian-locale-astro-blog

Different filenames (so the glob loader keeps them distinct), identical slug values (so the pairing function can find the counterpart), opposite lang values (so the correct file is served at each locale's route). This is the entire convention. It is a naming contract, not schema enforcement - the Zod schema does not validate slug uniqueness per locale, so breaking the contract silently produces a post without a counterpart rather than a build error. That is a known limit, and for a solo author it is acceptable.

How the switcher finds the counterpart

The key function is findPair in src/lib/posts.ts:

export function findPair(
  post: PostEntry,
  allPosts: PostEntry[],
): PostEntry | undefined {
  const targetLang = post.data.lang === "en" ? "ru" : "en";
  return allPosts.find(
    (p) => p.data.slug === post.data.slug && p.data.lang === targetLang,
  );
}

It scans all posts for one that shares the current post's slug and has the opposite lang. If both invariants hold - identical slug, opposite lang - it returns the counterpart. If either fails it returns undefined.

switcherHref uses findPair to resolve the actual URLs:

export function switcherHref(
  post: PostEntry,
  allPosts: PostEntry[],
): { en: string; ru: string } {
  const pair = findPair(post, allPosts);
  const hasEn = post.data.lang === "en" || !!pair;
  const hasRu = post.data.lang === "ru" || !!pair;
  return {
    en: hasEn ? `/blog/${post.data.slug}/` : `/blog/`,
    ru: hasRu ? `/ru/blog/${post.data.slug}/` : `/ru/blog/`,
  };
}

The switcher gets two URLs: one for the EN version of this post, one for the RU version. If a counterpart exists, the URL points to the post. If it does not - say, I write a new EN post and have not translated it yet - the switcher falls back to the locale index (/blog/ or /ru/blog/), which is still a valid destination rather than a broken link. The fallback is the graceful degradation story: unilingual posts get a switcher that navigates to the blog index for the unsupported locale.

The per-post route pages (src/pages/blog/[slug].astro and src/pages/ru/blog/[slug].astro) call switcherHref at build time for each post and pass the resulting { en, ru } object to the layout's locale-switcher component. No runtime resolution needed - the URLs are baked into the static HTML.

SEO parity: hreflang and canonical

Each locale page emits a <link rel="canonical"> pointing at itself, plus <link rel="alternate" hreflang="..."> tags pointing at both locales. For a bilingual post pair, the EN page at /blog/adding-russian-locale-astro-blog/ announces its RU counterpart at /ru/blog/adding-russian-locale-astro-blog/, and the RU page at /ru/blog/adding-russian-locale-astro-blog/ announces the EN one. Search engines use hreflang to understand that these are the same content in different languages, not duplicate pages. Without hreflang, a crawler would see two pages with very similar structure and might penalize one as a duplicate.

The Astro sitemap integration (@astrojs/sitemap) picks up all emitted routes automatically, so both locale URLs appear in sitemap.xml without additional config. Canonical tags live in src/layouts/BaseLayout.astro and are derived from the current page's URL. The hreflang alternates use the same { en, ru } object from switcherHref when the page is a post, or the locale-prefixed index URLs when the page is not a post.

This is the part of the model that has to be hand-built in Astro - @nuxtjs/i18n handles all of this automatically via useLocaleHead. On Astro, the SEO meta is explicit HTML in the layout, and the values are computed from the same findPair/switcherHref utilities. More code, more control, same result.

What this is not

A few honest limits worth naming before you adapt this pattern.

There is no t() runtime for post bodies. Each MDX file is the translation. If you add a sentence to the EN post, the RU post does not update until you update it manually. This is a feature for a solo author who writes directly in both languages - the translation is first-class content, not a key lookup. It would not scale to a team with a translation pipeline or a back-catalogue of a hundred posts needing full parity.

Slug uniqueness per locale is a convention, not a constraint. The Zod schema does not reject two EN posts with the same slug. If that happens, the dynamic route ([slug].astro) would render whichever the query returns first and silently drop the other. For a personal blog this is caught in review; for a multi-author setup it is a footgun that would need an explicit build-time check.

The bilingual model is shallow: one pair today, and the existing English posts have no RU counterparts. The switcher falls back gracefully for those posts (EN switcher hit on an EN-only post lands on /ru/blog/), so the site is not broken, but it is also not fully mirrored. Expanding coverage means adding RU files one by one, which is the deliberate design choice for a solo site.

The Astro i18n routing docs at docs.astro.build/en/guides/internationalization cover the prefixDefaultLocale options and the routing config in detail. What they do not cover - because it is application-specific - is the file-pairing convention and the switcher fallback logic described above.

The payoff

The same authoring loop that makes adding an EN post trivial (drop an MDX file, done) now works for bilingual pairs: drop two MDX files with matching slugs and opposite langs, and the switcher, the hreflang, the sitemap, and both locale indexes light up automatically. The infrastructure cost was a naming convention and about fifty lines of utility code in src/lib/posts.ts.

The verification is live: open this post, hit the language switcher in the header, and you will navigate to the Russian version of this page. findPair found the counterpart by slug match; switcherHref resolved the /ru/blog/... URL; the layout baked it into the static HTML at build time. No runtime logic, no API call, no redirect. That is the whole model.