Type something to search...
How to add internationalized (i18n) routing in Astro?

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 for Astro.currentLocale.
  • site: not required for basic routing, but needed for absolute URLs such as hreflang links 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:

HelperReturns
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:

SettingDefault language URLWhere 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.astro with 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 set redirectToDefaultLocale: true, and that option is only allowed when prefixDefaultLocale is true. 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.html was a redirect page to /about/, again with a two-second meta refresh in the static build.
  • "rewrite": dist/fr/about/index.html contained 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 undefined if 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.domains maps locales to their own domain, such as fr: "https://fr.example.com". It requires site, 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 like redirectToDefaultLocale(), notFound() and the middleware() function from astro: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.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper