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.

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.

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 as defined by RFC 5646 (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 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}

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

  • 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.
  • 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.
  • 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.