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:
- 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>/. - Translations pair up without bookkeeping. No
translationKeyfield in frontmatter, no central mapping file that goes stale. The file layout itself should say what’s a translation of what. - 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.xmland/ja/rss.xml, each with the right<language>tag — same rest-parameter pattern as the pages. langandog:localecome 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
getPostscalls. - 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.