i18n Routing in Astro
Astro 4.0 turned internationalization from a folder convention into configuration. A short i18n block in astro.config.mjs declares the locales, the default language, whether that default carries a URL prefix and what should happen when a page is missing in a locale. Astro then exposes the current locale to every component and provides helpers that build correct localized URLs. What it deliberately does not do is organise or translate your content — that stays your decision.
This guide sets up routing for an Astro documentation site in English, German, French and Japanese, with English unprefixed at the root, content collections split by locale, a language switcher driven by the same data as the hreflang alternates, and a fallback strategy for pages not yet translated. It belongs to Internationalization for Static Sites, and the Starlight-specific version is in Internationalizing Starlight Docs.
Prerequisites
- Astro 4.16 or 5.x with a static output (
output: 'static', the default). - The
siteoption set to the production origin, so absolute URLs and alternates are correct. - A content collection for pages (here
docs) using the Content Layer or legacy collections.
Step 1: Configure Locales
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://docs.example.com',
trailingSlash: 'always',
i18n: {
locales: ['en', 'de', 'fr', 'ja'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false,
redirectToDefaultLocale: false
},
fallback: { fr: 'en', ja: 'en' }
}
});
prefixDefaultLocale: false keeps English at /guide/install/, so an existing English site keeps every URL it already has. German lives at /de/guide/install/. The fallback map says that French and Japanese pages missing from the build should be generated from English; German has no fallback because the German translation was complete at launch, and a missing German page should be a build-visible gap rather than a silent copy.
Step 2: Organise Content by Locale
Astro's router maps URLs to files in src/pages, but most documentation lives in a collection. Put a locale folder at the top of the collection and generate routes from it:
src/content/docs/
en/guide/install.md
de/guide/install.md
fr/guide/install.md
ja/guide/install.md
---
// src/pages/[...slug].astro (English, unprefixed)
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const docs = await getCollection('docs', (e) => e.id.startsWith('en/'));
return docs.map((entry) => ({
params: { slug: entry.id.replace(/^en\//, '').replace(/\.md$/, '') },
props: { entry }
}));
}
---
---
// src/pages/[locale]/[...slug].astro (prefixed locales)
export async function getStaticPaths() {
const docs = await getCollection('docs', (e) => !e.id.startsWith('en/'));
return docs.map((entry) => {
const [locale, ...rest] = entry.id.replace(/\.md$/, '').split('/');
return { params: { locale, slug: rest.join('/') }, props: { entry } };
});
}
---
Two route files are clearer than one route with an optional locale segment, because the unprefixed default and the prefixed locales have different parameter shapes.
Step 3: Locale-Aware Links and the Switcher
Hard-coded links (href="/de/guide/") are the main source of i18n bugs. Use the helpers from astro:i18n, which respect prefixDefaultLocale and trailingSlash:
---
import { getRelativeLocaleUrl } from 'astro:i18n';
const locale = Astro.currentLocale ?? 'en';
---
<a href={getRelativeLocaleUrl(locale, 'guide/install')}>Install</a>
For the language switcher, do not compute sibling URLs from the current path — translators may localize slugs. Use the same translation-key map that produces the hreflang alternates, as described in hreflang and Language Alternates on Static Sites. The switcher then offers only languages that really have the page and links to the right slug in each.
---
import { alternatesFor } from '../lib/alternates';
const { entry } = Astro.props;
const alts = alternatesFor(entry.data.translationKey);
const names = { en: 'English', de: 'Deutsch', fr: 'Français', ja: '日本語' };
---
<nav aria-label="Language">
<ul>
{alts.map((a) => (
<li><a href={a.url} hreflang={a.lang} lang={a.lang}
aria-current={a.lang === Astro.currentLocale ? 'true' : undefined}>{names[a.lang]}</a></li>
))}
</ul>
</nav>
Each option carries its own lang attribute so screen readers pronounce "Deutsch" in German, and aria-current marks the active language.
Step 4: UI Strings
Keep interface strings in one JSON file per locale and a tiny lookup function. Missing keys fall back to English at build time and are reported, so the build log lists every untranslated string:
// src/i18n/t.ts
import en from './en.json';
import de from './de.json';
import fr from './fr.json';
import ja from './ja.json';
const tables = { en, de, fr, ja } as const;
export function t(locale: string, key: keyof typeof en) {
const value = tables[locale]?.[key];
if (value === undefined && locale !== 'en') console.warn(`[i18n] ${locale} missing "${key}"`);
return value ?? en[key];
}
Step 5: Decide What Fallback Means
With fallback: { fr: 'en' }, Astro generates something at /fr/guide/troubleshooting/ even when only the English file exists. The default fallbackType: 'redirect' emits a redirect page to the English URL; 'rewrite' renders the English content at the French URL. For search and accessibility the redirect is safer: a page at a French URL with English text and lang="en" confuses readers, and with lang="fr" misleads screen readers and crawlers.
The documentation project used redirects for Japanese (where machine-translated stubs had previously caused complaints) and no fallback for German. A build-time report counted French pages served by fallback — 64 of 640 at launch, down to 9 by the end of the quarter — and fed the translation team's backlog.
Step 6: Sitemaps, Feeds and 404 Pages Per Locale
Three pieces of site furniture are easy to forget when routing goes multilingual.
The sitemap from @astrojs/sitemap accepts an i18n option with the default locale and a map of locale codes, and then emits xhtml:link alternates for URLs it can match by path. Because it matches by path, it misses pages whose slugs were localized; if translators localize slugs, generate the sitemap from the same translation-key map as the head alternates instead, or turn the integration's alternates off and keep them in the head only. Mixing a path-matched sitemap with key-matched head links produces contradictory signals, which search engines resolve unpredictably.
Feeds should be one per locale — /rss.xml for English and /de/rss.xml for German — each with the right <language> element and only that locale's entries. A combined feed makes subscribers read four languages and makes feed readers guess the language of each item.
The 404 page needs a localized variant. Astro generates 404.astro once; add src/pages/de/404.astro and friends, and configure the host to serve them for missing paths under each prefix. On Cloudflare Workers static assets, not_found_handling = "404-page" picks the nearest 404.html walking up the directory tree, so /de/missing/ gets the German 404 automatically. On Netlify, add a /de/* /de/404.html 404 rule to _redirects. A German reader landing on an English "Page not found" after following a stale link is small, but it is the kind of seam readers notice.
Finally, remember that everything above runs at build time. Nothing in the i18n configuration requires server rendering unless you use redirectToDefaultLocale or middleware; keeping output: 'static' means every localized page is a file on the CDN with the same cache behaviour as the English ones.
Measured Impact
The Astro prototype of the documentation site, four locales and 2,560 pages, built on an 8-vCPU GitHub Actions larger runner:
| Measure | Hand-built folders (Astro 3) | Built-in i18n (Astro 4.16) |
|---|---|---|
| Lines of routing and link code | 410 | 145 |
| Hard-coded locale links found by grep | 83 | 0 |
| Broken switcher links (Playwright crawl) | 17 | 0 |
Build time, median of five hyperfine runs | 268 s | 262 s |
| Pages served by fallback at launch | untracked | 64 (French), 121 (Japanese) |
Pitfalls & Rollback
- Middleware redirects in static output.
redirectToDefaultLocaleand browser-language detection need server rendering or an edge function; a static build cannot readAccept-Language. Use a root-only edge redirect as in Language Detection and Redirects at the Edge. trailingSlashmismatches. Helpers follow the config; hand-written links that disagree create redirect hops.- Locale codes in content IDs. If a collection entry ID starts with a locale that is not in
locales, its page is silently generated under an unknown prefix. Validate IDs in the collection schema. - Rewrite fallbacks with the wrong
lang. Setlangfrom the content's real language, not from the URL. - Rollback: the i18n block and helpers are additive. Removing the block while keeping the route files restores the previous URLs; nothing in the built output depends on runtime code.
Conclusion
Astro's i18n routing handles the mechanical part well — locale detection from the URL, prefix rules, fallbacks and link helpers — and leaves content organisation to you. Put a locale folder at the top of each collection, drive both the switcher and the alternates from a translation-key map, and choose redirect fallbacks unless you have a reason to keep the localized URL. On the prototype, that removed two thirds of the routing code and every broken switcher link, at no build-time cost.
FAQ
Which Astro version added built-in i18n routing?
Astro 4.0 introduced the i18n configuration block with locales, defaultLocale and routing options, and later 4.x releases added fallback and domain options. Earlier versions needed a hand-built folder structure and helper functions.
Does Astro translate content for me?
No. The i18n configuration handles URLs, the current locale, fallbacks and link helpers. Organising translated content, usually as a locale folder inside each content collection, and translating UI strings are up to you or to an integration such as Starlight.
What does the fallback option do on a static build?
For a locale listed in fallback, pages that do not exist in that locale are generated from the fallback locale, either as a redirect or, with fallbackType set to rewrite, as a copy of the fallback page served at the localized URL. Choose rewrite only if the fallback page clearly signals its language.
How do I get the current locale in a component?
Read Astro.currentLocale. It is derived from the URL according to the i18n configuration, so components do not need the locale passed down as a prop.
Related
- Parent: Internationalization for Static Sites — URL structure, alternates and workflows.
- Multilingual Sites with Hugo — the same site in Hugo's language model.
- hreflang and Language Alternates on Static Sites — the key map the switcher reuses.
- Internationalizing Starlight Docs — Starlight's layer on top of Astro i18n.
- Content Collections vs Eleventy Data Cascade — how collections shape content organisation.