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.

Astro i18n content collection resolution flow A prefixed URL maps to a locale plus slug, getStaticPaths queries the per-locale collection, a found entry renders while a missing entry resolves through configured fallback, and getRelativeLocaleUrl emits the translated link. URL prefix /fr/blog/hello locale + slug fr · blog/hello getStaticPaths emits path params per locale collection entry src/content/blog/fr/ fallback defaultLocale entry rendered page missing
A prefixed URL resolves to a locale and slug, getStaticPaths emits one path per locale, and a missing entry degrades to the defaultLocale before getRelativeLocaleUrl emits the outbound 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.

From config to emitted page in Astro i18n The i18n block in astro.config.mjs declares the default locale, the locale list and the routing mode. A content collection entry under a locale folder supplies the source. A dynamic route file maps collection entries onto URLs. The build emits one static page per locale-slug pair. How Astro resolves one localized page i18n config defaultLocale, locales, routing astro.config.mjs Content collection entry src/content/docs/de/guide.md the source Dynamic route src/pages/[locale]/docs/[...slug].astro the mapping Emitted page /de/docs/guide/ the output
Every localized URL is the product of these four layers agreeing on the same locale identifier.

Three keys drive everything:

  • defaultLocale — the locale served at the unprefixed root when prefixDefaultLocale is false.
  • locales — the array of supported locale tags. These must be valid BCP 47 tags — the canonical RFC 5646 identifiers (en, pt-BR, zh-Hant) — because Astro forwards them to the Intl APIs and the <html lang> attribute. You can also map a custom path segment to a list of codes (a codes array) so /spanish/ resolves to es.
  • routing — controls URL shape. prefixDefaultLocale: false (the default) keeps /about for the default locale and /fr/about for others; true forces /en/about for everyone. redirectToDefaultLocale and fallbackType ( '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:

Config, content tree, and route alignment in Astro i18n content collections Each configured locale needs a matching content subfolder to emit its own route. The en and fr rows align across config, tree, and routes and render native content; pt-BR is declared in config but has no folder, so it produces a fallback route serving en content under the pt-br URL. astro.config locales content/blog tree generated routes 'en' default locale blog/en/hello.md /blog/hello no prefix 'fr' prefixed locale blog/fr/hello.md /fr/blog/hello prefix added 'pt-BR' declared, no folder (missing folder) /pt-br/hello → en fallback
Each configured locale needs a matching content subfolder to emit its own route. 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.

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

  • getStaticPaths returns an empty array — the collection glob base is wrong or all entries are filtered as drafts. Log entries.length before mapping; an empty collection silently yields zero routes and no error.
  • Default locale leaks onto a prefixed URL — you set prefixDefaultLocale: false but hard-coded /en/ links. Always route through getRelativeLocaleUrl, which omits the prefix for the default locale.
  • Astro.currentLocale is undefined — the page is not under a path that matches a declared locale, or you are inside a hydrated island. Read it only in .astro files; 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 fallback map key is a locale not present in locales, or fallbackType is '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 like en_US (underscore) or pt-br mis-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 params throw a duplicate-path build error. Ensure the locale segment is always part of params, 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.

Part of Frontend Framework i18n & Component Routing.