Astro i18n with Content Collections
Astro’s astro:i18n routing builds clean per-locale URLs, but pairing it with content collections breaks the moment a translated entry is missing and you ship a raw 404 or a getStaticPaths route that yields zero pages — the tell-tale [getStaticPaths] Invalid or missing dynamic route or a silently empty dist/fr/. This page wires defaultLocale, locales, and routing.prefixDefaultLocale to per-locale collections so every locale resolves a page, links stay correct via getRelativeLocaleUrl, and a missing translation degrades through a configured fallback chain instead of crashing the static build.
The hard part is not the config block — it is keeping three sources of truth in sync: the locales declared in astro.config.mjs, the directory layout your content collection globs, and the path parameters getStaticPaths emits. When they drift, Astro either prerenders the wrong slug, mirrors the default locale onto a prefixed URL, or omits a locale entirely. The diagram below shows how a single request flows from URL prefix to the collection entry and back out as a translated link.
Prerequisites
Concept & spec: routing config and locale identifiers
astro:i18n is Astro’s built-in routing layer for multilingual sites. You declare it once in astro.config.mjs, and Astro injects a virtual module exposing helpers like getRelativeLocaleUrl, getAbsoluteLocaleUrl, and getLocaleByPath. It sits inside the broader frontend framework i18n and component routing layer as the static-site counterpart to server-rendered approaches.
Three keys drive everything:
defaultLocale— the locale served at the unprefixed root whenprefixDefaultLocaleisfalse.locales— the array of supported locale tags. These must be valid BCP 47 tags — the canonicalRFC 5646identifiers (en,pt-BR,zh-Hant) — because Astro forwards them to theIntlAPIs and the<html lang>attribute. You can also map a custom path segment to a list of codes (acodesarray) so/spanish/resolves toes.routing— controls URL shape.prefixDefaultLocale: false(the default) keeps/aboutfor the default locale and/fr/aboutfor others;trueforces/en/aboutfor everyone.redirectToDefaultLocaleandfallbackType('redirect'vs'rewrite') tune what happens for unmatched paths.
Locale negotiation itself — choosing fr over en from a request — is a separate concern Astro delegates to middleware or the platform; the same q-weighted parsing covered in locale negotiation strategies applies before a URL prefix is ever chosen. This page picks up the chain after a locale is resolved, turning a resolved locale into a prerendered, linkable page.
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'fr', 'pt-BR'],
routing: {
prefixDefaultLocale: false, // /about (en) and /fr/about, /pt-br/about
redirectToDefaultLocale: true,
fallbackType: 'rewrite',
},
fallback: {
'pt-BR': 'en', // missing pt-BR pages serve en content under the pt-br URL
fr: 'en',
},
},
});
The three sources of truth introduced above — the locales array, the content directory tree, and the routes getStaticPaths emits — only work when they line up row for row. When one drifts, the build either drops a locale or serves the wrong content:
en and fr align across all three columns; pt-BR is declared but has no folder, so it degrades to a fallback route serving en content under the /pt-br/ URL instead of a 404.Step-by-step implementation
1. Define a localizable content collection
Create a collection whose loader globs a per-locale directory tree. Each locale gets its own subfolder so a missing translation is simply an absent file, which you can detect at build time rather than at request time.
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
With files at src/content/blog/en/hello.md and src/content/blog/fr/hello.md, each entry’s id becomes en/hello and fr/hello — the locale is the first path segment, which the next step parses.
2. Generate one route per locale with getStaticPaths
The localized page lives at src/pages/[locale]/blog/[...slug].astro (or src/pages/[...locale]/... if you prefer optional prefixes). getStaticPaths splits each entry id into its locale and slug, then emits a path object per entry. This is what guarantees every locale resolves a page instead of a 404.
---
// src/pages/[locale]/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const entries = await getCollection('blog', (e) => !e.data.draft);
return entries.map((entry) => {
const [locale, ...rest] = entry.id.split('/');
return {
params: { locale, slug: rest.join('/') },
props: { entry },
};
});
}
const { entry } = Astro.props;
const { Content } = await render(entry);
const locale = Astro.params.locale;
// derive direction from the locale, not from a per-component prop
const dir = ['ar', 'he', 'fa', 'ur'].includes(locale) ? 'rtl' : 'ltr';
---
<html lang={locale} dir={dir}>
<body><article><h1>{entry.data.title}</h1><Content /></article></body>
</html>
Setting dir from the locale at the <html> level — rather than toggling it inside individual components — is what keeps RTL and bidirectional layout working once you add an Arabic or Hebrew locale to locales. The layout root becomes the single source of direction for the whole document.
3. Emit translated links with getRelativeLocaleUrl
Never hand-build /fr/... strings. Import the helper from astro:i18n so the path always respects prefixDefaultLocale and your custom codes mappings. Pull the active locale from Astro.currentLocale.
---
import { getRelativeLocaleUrl } from 'astro:i18n';
const locale = Astro.currentLocale ?? 'en';
// honors prefixDefaultLocale: returns "/about" for en, "/fr/about" for fr
const aboutUrl = getRelativeLocaleUrl(locale, 'about');
---
<a href={aboutUrl}>About</a>
4. Build a locale switcher that preserves the slug
A switcher must keep the reader on the same logical page. Strip the current locale prefix to recover the bare slug, then re-prefix it for the target locale.
---
import { getRelativeLocaleUrl } from 'astro:i18n';
const locales = ['en', 'fr', 'pt-BR'];
const current = Astro.currentLocale ?? 'en';
// path without the locale segment, e.g. "blog/hello"
const slug = Astro.url.pathname.replace(new RegExp(`^/${current}/?`), '');
---
<nav>{locales.map((l) =>
<a href={getRelativeLocaleUrl(l, slug)} aria-current={l === current ? 'page' : undefined}>{l}</a>
)}</nav>
5. Configure build-time fallback
The fallback map in astro.config.mjs (step shown in the config block above) tells Astro to serve defaultLocale content under a missing locale’s URL. With fallbackType: 'rewrite' the reader keeps the /fr/ URL but sees English content; with 'redirect' they are sent to /about. Combine this with a draft filter so unfinished translations never leak.
6. Build a per-locale listing page
A blog index at /fr/blog/ must show only French entries, not the whole collection. Because the locale is the first segment of each id, filter on it directly — one getStaticPaths still emits one index per locale.
---
// src/pages/[locale]/blog/index.astro
import { getCollection } from 'astro:content';
import { getRelativeLocaleUrl } from 'astro:i18n';
export async function getStaticPaths() {
const all = await getCollection('blog', (e) => !e.data.draft);
const locales = [...new Set(all.map((e) => e.id.split('/')[0]))];
return locales.map((locale) => ({
params: { locale },
props: { posts: all.filter((e) => e.id.startsWith(`${locale}/`)) },
}));
}
const { locale, posts } = { locale: Astro.params.locale, ...Astro.props };
---
<ul>{posts.map((p) =>
<li><a href={getRelativeLocaleUrl(locale, p.id.split('/').slice(1).join('/'))}>{p.data.title}</a></li>
)}</ul>
Deriving locales from the collection itself — rather than re-reading the config array — keeps the listing honest: a locale with zero non-draft posts produces no index page, which surfaces the missing-translation gap instead of hiding it behind an empty list.
Configuration reference
| Option | Type | Description / default |
|---|---|---|
i18n.defaultLocale |
string |
Locale served at the unprefixed root. No default — required when i18n is set. |
i18n.locales |
(string | { path: string; codes: string[] })[] |
Supported BCP 47 tags, or a path-to-codes mapping. Required. |
routing.prefixDefaultLocale |
boolean |
false: default locale has no prefix. true: every locale is prefixed. Default false. |
routing.redirectToDefaultLocale |
boolean |
When prefixDefaultLocale is true, redirect / to /<defaultLocale>/. Default true. |
routing.fallbackType |
'redirect' | 'rewrite' |
How a fallback locale serves content: 301 to the source URL, or rewrite in place. Default 'redirect'. |
routing.manual |
boolean |
Disable built-in middleware so you handle routing in src/middleware.ts. Default false. |
fallback |
Record<string, string> |
Map of targetLocale: sourceLocale used when a localized page is missing. Optional. |
domains |
Record<string, string> |
Per-locale domain mapping (server output + adapter only). Optional. |
Framework variants
Next.js (App Router). Next has no content-collection concept; the equivalent is the app/[locale]/ segment plus middleware-based negotiation. If you are choosing between the two for a docs-style site, the routing model differs sharply — see Next.js i18n routing setup for the middleware and generateStaticParams analogue to getStaticPaths.
SvelteKit. SvelteKit also uses filesystem routing but resolves locale in hooks.server.ts rather than a virtual module; its [locale] directory plays the role of Astro’s [locale] page. The prefix-stripping logic in the switcher above maps directly onto the approach in SvelteKit internationalization basics.
Vue / Nuxt islands. When Astro embeds a Vue or React island, the island does not see Astro.currentLocale. Pass the locale down as a prop (client:load components receive serialized props) and hand it to the Vue I18n Composition API or a React i18next provider inside the island so the framework’s runtime catalog matches the page locale.
Angular. Angular has no content-collection primitive; its build-time equivalent is the @angular/localize extract-and-compile flow that produces one bundle per locale. If you are porting a docs site from Astro to Angular, the directory-per-locale idea maps onto separate i18n build targets rather than a glob — see Angular localization module setup for the extract-i18n and per-locale outputPath configuration.
Node backend / SSR adapter. Under output: 'server', getRelativeLocaleUrl still works, but getStaticPaths is ignored — you read Astro.currentLocale from the request and query the collection per request instead of prerendering.
Verification
After building, assert that every locale produced a file for a known slug and that the default locale is unprefixed:
npm run build
# expect both files to exist (prefixDefaultLocale: false)
test -f dist/blog/hello/index.html # en (no prefix)
test -f dist/fr/blog/hello/index.html # fr
test -f dist/pt-br/blog/hello/index.html # pt-BR (or fallback en content)
echo "locale routes generated"
A CI gate that fails the build when a locale is missing pages catches drift between your config and your content tree:
# fail if any configured locale has zero generated blog pages
for loc in fr pt-br; do
count=$(find "dist/$loc/blog" -name index.html 2>/dev/null | wc -l)
[ "$count" -gt 0 ] || { echo "no pages for $loc"; exit 1; }
done
Common pitfalls
getStaticPathsreturns an empty array — the collection globbaseis wrong or all entries are filtered as drafts. Logentries.lengthbefore mapping; an empty collection silently yields zero routes and no error.- Default locale leaks onto a prefixed URL — you set
prefixDefaultLocale: falsebut hard-coded/en/links. Always route throughgetRelativeLocaleUrl, which omits the prefix for the default locale. Astro.currentLocaleisundefined— the page is not under a path that matches a declared locale, or you are inside a hydrated island. Read it only in.astrofiles; pass it as a prop into islands. The same server/client boundary is what causes a hydration mismatch after a locale switch in other frameworks.- Fallback serves a 404 instead of default content — the
fallbackmap key is a locale not present inlocales, orfallbackTypeis'redirect'and the source page itself is missing. Verify the fallback target page actually exists, and model the precedence as a proper graceful fallback chain rather than a single default. - Invalid locale tags break
Intl— tags likeen_US(underscore) orpt-brmis-cased in content fail BCP 47 parsing. Keep config tags canonical and lowercase only the URL segment. - Slug collisions across locales — two locales producing the same
paramsthrow a duplicate-path build error. Ensure the locale segment is always part ofparams, as in step 2.
FAQ
Do I need separate content collections per locale?
No. One collection with a per-locale subfolder is the cleanest pattern — the locale becomes the first segment of each entry id, and getStaticPaths splits it out. Separate collections per locale fragment your schema and force duplicate defineCollection calls; reach for them only if locales genuinely have different fields.
How does prefixDefaultLocale interact with getRelativeLocaleUrl?
getRelativeLocaleUrl reads your routing config, so it automatically omits the prefix for the default locale when prefixDefaultLocale is false, and adds it when true. That is exactly why you should never concatenate locale paths by hand — the helper is the single place the rule lives.
What happens when a translated content entry is missing?
If you configured a fallback map, Astro serves the source locale’s content under the missing locale’s URL — rewritten in place or via redirect, per fallbackType. Without a fallback entry, the URL simply has no generated page and returns a 404, because getStaticPaths never emitted it.
Can I use locale-in-frontmatter instead of subfolders?
Yes, but you lose the 1:1 mapping to getStaticPaths. You would read entry.data.locale, group entries, and synthesize params yourself, plus guard against two entries claiming the same locale+slug. Subfolders make the directory the source of truth and the build error catch collisions for free.
Does this work with Astro’s server output?
Partially. getRelativeLocaleUrl and the astro:i18n helpers work in any output mode, but getStaticPaths only prerenders under static/hybrid. With output: 'server' you query the collection per request using Astro.currentLocale and rely on middleware for negotiation rather than build-time path emission.
Related
- Next.js i18n routing setup — the App Router and middleware equivalent of Astro’s prefix routing and
getStaticPaths. - SvelteKit internationalization basics — filesystem-based
[locale]routing with server-hook locale resolution. - SvelteKit route prefixing for multiple locales — prerendered prefixed paths, the closest analogue to the build-time strategy here.
- Vue I18n Composition API guide — wiring a runtime catalog inside a hydrated island embedded in an Astro page.