p4ni.

Tutorial

Astro i18n Without a Plugin: Bilingual Content Collections

· 7 min read

On this page

This blog recently became bilingual: every article now exists in English at /blog/<slug>/ and in Japanese at /ja/blog/<slug>/, with hreflang annotations, a language switcher, per-locale RSS feeds, and per-locale OG images. The part that might be interesting if you’re planning the same: I did it with no i18n plugin and no library — just content collections, one rest-parameter route, and about two hundred lines of plain TypeScript that I fully understand.

Plugins earn their keep when you have dozens of locales or need runtime language negotiation. For the common multilingual case — a static site, two or three languages, English URLs that must not change — Astro’s own primitives are enough, and staying on them means there’s no translation layer to debug when something renders in the wrong language. Here’s the whole design.

The constraints that shaped it

Three requirements, all of them common:

  1. Existing URLs keep working, unchanged. Every URL this site had ever published was un-prefixed English at /blog/<slug>/, and search engines had already indexed them. Moving English under /en/ would have meant redirecting every one of them — a cost with no benefit. So the default locale stays un-prefixed, and only translations get a prefix: /ja/blog/<slug>/.
  2. Translations pair up without bookkeeping. No translationKey field in frontmatter, no central mapping file that goes stale. The file layout itself should say what’s a translation of what.
  3. A missing translation must fail loudly. Untranslated UI strings shouldn’t silently fall back to English on a Japanese page — I wanted a type error.

One collection, locale folders

The content lives in one blog collection with a folder per locale:

src/content/blog/
├── en/
│   ├── deploy-astro-to-cloudflare-workers.mdx
│   └── astro-og-images-satori.mdx
└── ja/
    ├── deploy-astro-to-cloudflare-workers.mdx
    └── astro-og-images-satori.mdx

Because the glob loader is rooted at src/content/blog, every entry’s id arrives as <locale>/<slug> — the locale is carried by the data itself, no frontmatter required. One helper splits it:

// src/posts.ts
export function splitId(id: string): { locale: Locale; slug: string } {
  const [first, ...rest] = id.split('/');
  return isLocale(first) && rest.length > 0
    ? { locale: first, slug: rest.join('/') }
    : { locale: DEFAULT_LOCALE, slug: id };
}

export async function getPosts(locale: Locale): Promise<Post[]> {
  const posts = await getCollection('blog', publishedOnly);
  return posts
    .filter((post) => splitId(post.id).locale === locale)
    .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}

And this layout is the pairing mechanism: two posts that share a slug across locale folders are translations of each other. The two deploy-astro-to-cloudflare-workers.mdx files above are linked by nothing more than their filename — that’s what hreflang and the language switcher are built from. A Japanese-only article simply uses a slug that doesn’t exist under en/, and everything degrades gracefully: the hreflang alternates drop out, and the switcher falls back to the other language’s home page instead of linking to a 404.

One route file, both languages

URLs come from a [...locale] rest parameter, so a single route file renders every language. The trick is that Astro drops a rest segment whose param is undefined — which is exactly what an un-prefixed default locale needs:

// src/pages/[...locale]/blog/[slug].astro
export async function getStaticPaths() {
  const paths = [];
  for (const locale of await activeLocales()) {
    for (const post of await getPosts(locale)) {
      paths.push({
        params: {
          locale: locale === DEFAULT_LOCALE ? undefined : locale,
          slug: splitId(post.id).slug,
        },
        // The real version also passes the locales this slug exists in —
        // that's what the hreflang section below is built from.
        props: { post },
      });
    }
  }
  return paths;
}

English pages generate at /blog/<slug>/, Japanese at /ja/blog/<slug>/, from the same template. The index, tag pages, and RSS endpoint follow the identical pattern — the whole routing layer is this one idea applied a handful of times.

Astro does ship built-in i18n routing config (i18n.locales, prefixDefaultLocale and friends), and it’s fine — but it mostly governs routing, and with content collections the routing above is already trivial. The config would not have removed any of the code in this post, so I skipped it. If you need automatic redirects or language negotiation on a server, that’s where it earns a look.

hreflang from the slug pairing

Search engines need to know the two pages are the same article in different languages, or they’ll treat them as unrelated (or worse, as competitors). Each page’s head lists every locale the slug exists in, plus x-default pointing at the English version:

<link rel="alternate" hreflang="en" href="https://astro.p4ni.com/blog/astro-og-images-satori/" />
<link rel="alternate" hreflang="ja" href="https://astro.p4ni.com/ja/blog/astro-og-images-satori/" />
<link rel="alternate" hreflang="x-default" href="https://astro.p4ni.com/blog/astro-og-images-satori/" />

The rules that matter, because hreflang fails silently when you break them:

  • Annotations must be reciprocal. The English page lists Japanese, and the Japanese page lists English — one-directional annotations are ignored.
  • Every page lists itself, not just its alternates.
  • URLs must be absolute and must match the canonical exactly — same trailing slash, same host.
  • Only emit alternates that exist. This falls out of the slug pairing for free: the set of hreflang links is the set of locale folders containing the slug.

The same pairing feeds the visible language switcher. Where hreflang must be exact and simply drops out for an untranslated article, the switcher stays useful instead: with no translation to point at, it links to the other language’s home page rather than a 404. Both behaviors read the same slug-grouping function, so they can’t drift apart.

The typed UI dictionary

Templates need translated chrome — navigation, dates, footer, the “Updated” label. All of it lives in one file, and the type system enforces completeness:

// src/i18n.ts
const en = {
  'nav.articles': 'Articles',
  'post.updated': 'Updated',
  // …every UI string on the site
} as const;

export type UiKey = keyof typeof en;

// Typed against en's keys: forget one, and it's a type error.
const ja: Record<UiKey, string> = {
  'nav.articles': '記事',
  'post.updated': '更新',
  // …
};

const ui = { en, ja };

export function useTranslations(locale: Locale) {
  return (key: UiKey, vars?: Record<string, string | number>) =>
    interpolate(ui[locale][key], vars);
}

Record<UiKey, string> is the entire enforcement mechanism, and it’s the detail I’d keep above all others: add a string to en and the missing ja entry is a type error — red in the editor, and a failure in astro check if you run it in CI (worth wiring up, since astro build alone doesn’t type-check). This is the requirement that usually sells an i18n library, and here it’s one type annotation.

Components never hardcode copy; they call t('nav.articles') with the locale read from the URL. That discipline — all strings in the dictionary, no exceptions — is what keeps the second language complete over time, because completeness is type-checked rather than reviewed.

Don’t ship an empty locale

One subtlety worth stealing: locale routes only generate for languages that actually have published posts.

export async function activeLocales(): Promise<Locale[]> {
  const posts = await getCollection('blog', publishedOnly);
  const withPosts = new Set(posts.map((post) => splitId(post.id).locale));
  return LOCALES.filter((l) => l === DEFAULT_LOCALE || withPosts.has(l));
}

While I was translating the backlog, the Japanese section didn’t exist in production at all — no empty /ja/blog/ index for crawlers to find, no switcher pointing at a hollow section. Committing the first Japanese post is the single action that switches the locale on: its routes, the switcher, and the hreflang annotations all key off this one function. Combined with scheduled publishing, the whole Japanese launch was: translate, set a pubDate, let the daily build flip everything at once.

The details that round it out

  • Per-locale RSS at /rss.xml and /ja/rss.xml, each with the right <language> tag — same rest-parameter pattern as the pages.
  • lang and og:locale come from small lookup tables (en / ja, en_US / ja_JP), read from the first URL segment.
  • llms.txt went bilingual too — each locale’s index describes that locale’s pages in its own language, generated from the same getPosts calls.
  • Sitemap includes both locales automatically, since they’re all just static paths.

The theme I sell, Almanac, stays English-only for now — but this is the pattern I’d fold into it if buyers ask, precisely because it adds zero dependencies to a codebase a customer has to own.

Two hundred lines sounds like more work than npm install, but every one of those lines is ordinary Astro — the same getStaticPaths and getCollection you already use. When the language switcher shows the wrong thing, you debug your own ten-line function, not a plugin’s routing middleware. For two locales, I’d make the same call again without hesitating.