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. This page wires defaultLocale, locales, and routing.prefixDefaultLocale to per-locale collections so every locale resolves a page, links stay correct via getRelativeLocaleUrl, and fallback degrades gracefully 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 as defined by RFC 5646 (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 resolution applies before a URL prefix is ever chosen.
// 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',
},
},
});
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);
---
{entry.data.title}
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');
---
About
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}/?`), '');
---
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.
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 vue-i18n or react-i18next inside the island so the framework’s runtime catalog matches the page locale.
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.- 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. - 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.