
How to add internationalized (i18n) routing in Astro?
To add internationalised routing in Astro, add an i18n object to astro.config.mjs with a list of locales and a defaultLocale, then create one folder per language inside src/pages/, such as src/pages/es/ and src/pages/fr/. Astro's file-based routing turns those folders into /es/... and /fr/... URLs, while pages for the default language stay at the root. The astro:i18n module gives you helpers like getRelativeLocaleUrl() to build correct links, Astro.currentLocale tells each page which language it's in, and options such as prefixDefaultLocale and fallback control URL structure and what happens when a translation is missing.
Astro's i18n routing handles URLs, not translation. You still write or load the translated content yourself, but you don't need to hand-build every localised link or language redirect. In this article you'll learn how to configure locales, organise localised pages, build links and a language picker, add hreflang tags, choose between prefixed and unprefixed default URLs, set up fallbacks for untranslated pages, detect the visitor's preferred language on demand, and where i18n sits in Astro 7's request pipeline.
Configuring Locales
Start with the i18n config. locales and defaultLocale are required:
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://example.com",
i18n: {
locales: ["en", "es", "fr"],
defaultLocale: "en",
},
});
- locales: every language you support. Astro doesn't enforce a format, but lowercase codes with hyphens, such as
"pt-br", are the most compatible. - defaultLocale: one of the
locales, used for unprefixed URLs and as the default forAstro.currentLocale. - site: not required for basic routing, but needed for absolute URLs such as
hreflanglinks and for some advanced features.
Organising Localised Pages
With the default settings, pages for your default language live at the root of src/pages/, and every other language gets a folder whose name matches its locale exactly:
src/pages/
index.astro -> /
about.astro -> /about/
es/
index.astro -> /es/
about.astro -> /es/about/
fr/
index.astro -> /fr/
There's nothing special about these folders as far as file-based routing is concerned. What the i18n config adds is knowledge of which folder belongs to which language, so the helpers and Astro.currentLocale work. The locale folders don't need to be at the root of src/pages/; a src/pages/blog/es/ folder works too.
For larger sites, you'll often generate localised pages from a content collection with a [locale] parameter and getStaticPaths() rather than duplicating files. See how to create dynamic routes with getStaticPaths for that pattern.
Translating UI Strings
Page content can live in each locale's files, but shared interface text such as navigation labels should come from one place. A small dictionary module is enough to start:
// src/i18n/ui.ts
export const ui = {
en: { "nav.home": "Home", "nav.about": "About", "lang.label": "English" },
es: {
"nav.home": "Inicio",
"nav.about": "Acerca de",
"lang.label": "Español",
},
fr: {
"nav.home": "Accueil",
"nav.about": "À propos",
"lang.label": "Français",
},
} as const;
export type Locale = keyof typeof ui;
export function t(locale: string | undefined, key: keyof (typeof ui)["en"]) {
const strings = ui[(locale ?? "en") as Locale] ?? ui.en;
return strings[key];
}
The t() helper falls back to English when a locale or key is missing, so an untranslated label never renders as undefined.
Building Links with astro:i18n
Hard-coding /es/about/ works until you change your URL strategy. The astro:i18n module computes links from your config instead. The most useful helpers:
| Helper | Returns |
|---|---|
getRelativeLocaleUrl(locale, path?) | A path such as /es/about/ |
getAbsoluteLocaleUrl(locale, path?) | A full URL using site, such as https://example.com/es/about/ |
getRelativeLocaleUrlList(path?) | Relative paths for every locale |
getAbsoluteLocaleUrlList(path?) | Absolute URLs for every locale |
Here's a shared layout that uses them for navigation and hreflang tags:
---
// src/components/Layout.astro
import { getAbsoluteLocaleUrl, getRelativeLocaleUrl } from "astro:i18n";
import LanguagePicker from "./LanguagePicker.astro";
import { t } from "../i18n/ui";
interface Props {
title: string;
path?: string;
}
const { title, path = "" } = Astro.props;
const locale = Astro.currentLocale ?? "en";
---
<html lang={locale}>
<head>
<meta charset="utf-8" />
<title>{title}</title>
<link rel="alternate" hreflang="en" href={getAbsoluteLocaleUrl("en", path)} />
<link rel="alternate" hreflang="es" href={getAbsoluteLocaleUrl("es", path)} />
<link rel="alternate" hreflang="fr" href={getAbsoluteLocaleUrl("fr", path)} />
</head>
<body>
<nav>
<a href={getRelativeLocaleUrl(locale, "")}>{t(locale, "nav.home")}</a>
<a href={getRelativeLocaleUrl(locale, "about")}>{t(locale, "nav.about")}</a>
</nav>
<LanguagePicker path={path} />
<main>
<slot />
</main>
</body>
</html>
Astro.currentLocale is computed from the URL: es for pages under /es/, and the defaultLocale for unprefixed pages. It works in prerendered pages too, so the lang attribute and the labels are always right. Each page passes its locale-independent path, for example:
---
// src/pages/es/about.astro
import Layout from "../../components/Layout.astro";
---
<Layout title="Acerca de" path="about">
<h1>Sobre nosotros</h1>
<p>currentLocale: {Astro.currentLocale}</p>
</Layout>
A Language Picker
The picker links to the same page in every language:
---
// src/components/LanguagePicker.astro
import { getRelativeLocaleUrl } from "astro:i18n";
import { ui, type Locale } from "../i18n/ui";
interface Props {
path?: string; // the page path without a locale, e.g. "about"
}
const { path = "" } = Astro.props;
const current = Astro.currentLocale;
const locales = Object.keys(ui) as Locale[];
---
<ul class="language-picker">
{locales.map((locale) => (
<li>
<a
href={getRelativeLocaleUrl(locale, path)}
hreflang={locale}
aria-current={locale === current ? "true" : undefined}
>
{ui[locale]["lang.label"]}
</a>
</li>
))}
</ul>
The built /es/about/ page contained:
<html lang="es"><head><meta charset="utf-8"><title>Acerca de</title><link rel="alternate" hreflang="en" href="https://example.com/about/"><link rel="alternate" hreflang="es" href="https://example.com/es/about/"><link rel="alternate" hreflang="fr" href="https://example.com/fr/about/"></head><body><nav><a href="/es/">Inicio</a><a href="/es/about/">Acerca de</a></nav><ul class="language-picker"><li><a href="/about/" hreflang="en">English</a></li><li><a href="/es/about/" hreflang="es" aria-current="true">Español</a></li><li><a href="/fr/about/" hreflang="fr">Français</a></li></ul><main><h1>Sobre nosotros</h1><p>currentLocale: es</p></main></body></html>
Notice that the English link is /about/, with no prefix, because en is the default locale. The helpers also added trailing slashes in this project, matching the default directory build format. Hard-coded links would have to replicate all of that by hand. Write each language's name in that language ("Español", not "Spanish") so visitors can find their own.
Prefixing the Default Locale
The routing.prefixDefaultLocale option decides whether default-language URLs carry a prefix:
| Setting | Default language URL | Where default pages live |
|---|---|---|
false (default) | /about/ | src/pages/about.astro |
true | /en/about/ | src/pages/en/about.astro |
With true, every language is treated the same way, which some teams find easier to maintain:
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://example.com",
i18n: {
locales: ["en", "es", "fr"],
defaultLocale: "en",
routing: {
prefixDefaultLocale: true,
redirectToDefaultLocale: true,
},
},
});
src/pages/
index.astro (still required)
en/
index.astro -> /en/
about.astro -> /en/about/
es/
index.astro -> /es/
about.astro -> /es/about/
Two things to know:
- A root index page is still required: when I deleted
src/pages/index.astrowith this config, the build failed with[MissingIndexForInternationalization] Could not find index page. A root index page is required in order to create a redirect to the index URL of the default locale. - redirectToDefaultLocale defaults to false: since Astro 6, the home URL doesn't redirect to
/en/unless you setredirectToDefaultLocale: true, and that option is only allowed whenprefixDefaultLocaleistrue. Older tutorials that rely on the previous default will behave differently.
With both options on, the static build wrote a redirect page at dist/index.html:
<!doctype html><title>Redirecting to: /en/</title><meta http-equiv="refresh" content="2;url=/en/"><meta name="robots" content="noindex"><link rel="canonical" href="https://example.com/en/"><body><a href="/en/">Redirecting from <code>/</code> to <code>/en/</code></a></body>
Note the two-second delay in content="2;url=/en/". On a static host, visitors briefly see that page. With an adapter, the redirect happens on the server instead.
Fallbacks for Missing Translations
Translations rarely land all at once. The fallback option names a language to use for each locale when a page doesn't exist:
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://example.com",
i18n: {
locales: ["en", "es", "fr"],
defaultLocale: "en",
fallback: {
fr: "en",
},
routing: {
prefixDefaultLocale: false,
fallbackType: "rewrite",
},
},
});
My French folder had only index.astro, no about.astro. With fallback: { fr: "en" }, Astro still built /fr/about/. What it contained depended on fallbackType:
- "redirect" (default):
dist/fr/about/index.htmlwas a redirect page to/about/, again with a two-second meta refresh in the static build. - "rewrite":
dist/fr/about/index.htmlcontained the English page content, served at the French URL.
The rewrite version has a subtlety. Because Astro.currentLocale was fr, my layout rendered <html lang="fr"> and French navigation labels around English content:
<html lang="fr"><head><meta charset="utf-8"><title>About</title>...<nav><a href="/fr/">Accueil</a><a href="/fr/about/">À propos</a></nav>...<main><h1>About us</h1><p>currentLocale: fr</p></main>
That keeps visitors in French navigation, which is usually what you want, but the lang attribute is wrong for the body text. If accessibility and SEO matter for those pages, set a lang attribute on the content itself, or use the redirect fallback. Without any fallback entry, a missing translation is simply a 404.
Detecting the Visitor's Language
On pages rendered on demand, Astro reads the browser's Accept-Language header and compares it to your locales:
- Astro.preferredLocale: the best match, or
undefinedif none of the visitor's languages are supported. - Astro.preferredLocaleList: every supported locale the browser asked for, in order.
A language-detection page that redirects to the best match:
---
// src/pages/language.astro
export const prerender = false;
import { getRelativeLocaleUrl } from "astro:i18n";
const preferred = Astro.preferredLocale;
const list = Astro.preferredLocaleList;
if (preferred && preferred !== Astro.currentLocale) {
return Astro.redirect(getRelativeLocaleUrl(preferred, ""), 302);
}
---
<p>preferred: {String(preferred)} | list: {(list ?? []).join(", ")}</p>
Testing the built Node server with different headers:
Accept-Language: es-ES,es;q=0.9,en;q=0.8 -> 302 /es/
Accept-Language: fr;q=0.9,es;q=0.8 -> 302 /fr/
Accept-Language: en-GB,en;q=0.9,fr;q=0.5 -> 200 preferred: en | list: en, fr
Accept-Language: de-DE,de;q=0.9 -> 200 preferred: undefined | list:
es-ES matched the es locale, and German, which the site doesn't support, produced undefined. When I sent no Accept-Language header at all, the list was also empty. Two recommendations:
- Never trap visitors: use detection as a suggestion or for the first visit only, and always let people switch languages with the picker. A cookie can remember their choice; see cookies in Astro.
- Don't redirect search engines away from content: crawlers often send no language header. Every localised URL should be reachable directly.
Custom Paths, Domains and Manual Routing
A few more options are worth knowing about:
- Custom locale paths: a locale can be an object, such as
{ path: "french", codes: ["fr", "fr-BR", "fr-CA"] }, to serve several language codes from one folder with a URL of your choice. - Domains:
i18n.domainsmaps locales to their own domain, such asfr: "https://fr.example.com". It requiressite,output: "server"with no prerendered pages, and the Node or Vercel adapter. - Manual routing:
routing: "manual"disables Astro's i18n handling so you can write your own in middleware, using helpers likeredirectToDefaultLocale(),notFound()and themiddleware()function fromastro:i18n.
i18n in Astro 7's Request Pipeline
Astro's i18n logic runs after your pages and middleware: it waits for the response and then checks it against your i18n config, handling locale redirects, 404s for unknown locales and fallbacks. In Astro 7, that stage is the i18n() handler in astro/fetch. If you write a custom src/fetch.ts that composes handlers individually, include it after rendering:
// src/fetch.ts
import { FetchState, middleware, pages, i18n } from "astro/fetch";
export default {
async fetch(request: Request): Promise<Response> {
const state = new FetchState(request);
const response = await middleware(state, (s) => pages(s));
return i18n(state, response);
},
};
Leave it out and your i18n redirects and fallbacks won't run for on-demand requests. If you don't have a src/fetch.ts, or you use the all-in-one astro() handler, this is handled for you.
FAQ: i18n Routing in Astro
No. Astro's i18n routing manages URLs, locale detection, links and fallbacks. You still write translated pages or load translated content yourself, for example from localised content collections or a CMS.
With the default prefixDefaultLocale: false, they live at the root of src/pages and have no URL prefix. With prefixDefaultLocale: true, they go in a folder named after the default locale, such as src/pages/en/.
Use getRelativeLocaleUrl(locale, path) from astro:i18n, passing the page path without a locale. It applies your prefix settings and trailing slash style, so links stay correct if your configuration changes.
Since Astro 6, redirectToDefaultLocale defaults to false. Set it to true alongside prefixDefaultLocale: true if you want the root URL to redirect to the default locale.
Without a fallback, the URL returns a 404. With an i18n.fallback entry for that locale, Astro either redirects to the fallback language's page or, with fallbackType: "rewrite", shows its content at the requested URL.
Yes, on pages rendered on demand. Astro.preferredLocale and Astro.preferredLocaleList compare the browser's Accept-Language header with your locales. Prerendered pages only have Astro.currentLocale, which comes from the URL.
Conclusion
Internationalised routing in Astro starts with two config values, locales and defaultLocale, and one folder per language in src/pages/. From there, Astro.currentLocale tells each page its language, and the astro:i18n helpers build links, language pickers and hreflang tags that follow your configuration instead of hard-coded paths.
Choose your URL structure early with prefixDefaultLocale, remember that redirectToDefaultLocale is now off by default, and use fallback to keep untranslated pages useful while translations catch up. On demand, Astro.preferredLocale can point visitors towards their own language, but always leave the final choice to them.


