i18n Astro

A multilingual Astro site with Lingui: routes, hreflang and a language switch that keeps your place

How this site serves English, Polish and Spanish from one static Astro build: locale routes, translated slugs, hreflang and a switch that keeps your page.

By Mateusz Muszarski Updated 7 min read Po polsku

This site serves English, Polish and Spanish from one static Astro build. Every page lives under /en/, /pl/ or /es/, Lingui catalogs hold the interface strings, and long texts like this post are one file per language. One small module builds every URL, so the canonical link, the hreflang tags and the language switch can't disagree.

TL;DR

  • Every URL starts with its locale, and one path builder (routes.ts) makes the canonical, hreflang and switcher links.
  • Lingui handles the interface: .po catalogs compile on import, and a production build fails when a Polish or Spanish string is missing.
  • The switch keeps your page when a translation exists and otherwise lands on a section in your language, never on English under a /pl/ address.

What does the URL scheme look like?

The home page on three phones in English, Polish and Spanish: the same layout, with the Spanish lines running longest
/en/, /pl/ and /es/: one layout, three catalogs. The layout has to take the longest language, here Spanish.
Rule Value
Locales en (source and default), pl, es
Prefix always the locale: /en/, /pl/, /es/
No prefix /, /cv/, /blog/<slug>/ redirect by the browser's language
Trailing slash always
<html lang> and hreflang bare codes: en, pl, es
Slugs shared (work, cv, my-life); blog posts may differ

Google recommends "different URLs for each language version of a page rather than using cookies or browser settings to adjust the content language on the page". A locale prefix is the simplest form of that.

What happens on an address without a locale?

A printed CV says muszarski.com/cv, not /en/cv/. So since 8 October 2026 every page also answers without its locale. vercel.json then redirects at the edge with a 307 (temporary), picked by the first language in the Accept-Language header: Polish goes to /pl/, Spanish to /es/, anything else to /en/. A post's own slug goes to the language it is written in.

Google says its crawler "sends HTTP requests without setting Accept-Language", so bots get English. Google also asks sites to "avoid automatically redirecting users from one language version of a site to a different language version". These redirects never do that: they only pick a version for an address that has none. A unit test runs the rules against every page and post, so a new Polish post without its redirect fails.

Why a [locale] folder and not Astro's i18n config?

Astro has an i18n routing API: you list your locales, pick a default, decide whether it gets a prefix and build links with helpers such as getRelativeLocaleUrl(). Its manual mode disables Astro's i18n middleware "so that you can implement your own custom logic".

I didn't turn it on. The pages sit in src/pages/[locale]/, a dynamic route. In a static build "all routes must be determined at build time", so each page's getStaticPaths() returns one entry per locale, and the blog one per post file. With every locale prefixed, the rules fit in one small module. If you'd rather not write it, start with the built-in API.

How does one module build every URL?

src/lib/routes.ts works with one shape: a locale-free path. A page with the same slug everywhere passes a string ("work"); a page whose slug changes per language passes a map with one entry per locale.

type LocalizedPath = string | Partial<Record<Locale, string>>;

export function pathTo(locale: Locale, path: LocalizedPath = ""): string {
  const local = typeof path === "string" ? path : path[locale];
  if (local === undefined) throw new Error(`No ${locale} version`);
  return `/${locale}/${clean(local)}`; // clean() adds the trailing slash
}

export function switchPath(path: LocalizedPath, to: Locale, fallback: LocalizedPath = ""): string {
  if (localesOf(path).includes(to)) return pathTo(to, path);
  return localesOf(fallback).includes(to) ? pathTo(to, fallback) : pathTo(to);
}

pathTo throws when a page has no version in a locale, so a link to a missing translation fails the build instead of shipping. The canonical tag, the hreflang tags, the navigation and the switcher all go through it, and unit tests pin each case.

How do hreflang and x-default work here?

Google's rules are short. Each version "must list itself as well as all other language versions". If two pages don't point to each other, "the tags will be ignored". And the URLs "must be fully-qualified". alternates() turns that into code:

export function alternates(site: string, path: LocalizedPath): Alternate[] {
  const locales = localesOf(path);
  if (locales.length < 2) return []; // one language: nothing to pair
  const origin = new URL(site).origin;
  const links = locales.map((l) => ({ hreflang: l, href: origin + pathTo(l, path) }));
  const fallback = locales.includes("en") ? "en" : locales[0];
  links.push({ hreflang: "x-default", href: origin + pathTo(fallback, path) });
  return links;
}

This is the head of the Polish version of my Lingui post, as built:

<link rel="canonical" href="https://muszarski.com/pl/blog/lingui-czy-i18next/">
<link rel="alternate" hreflang="en" href="https://muszarski.com/en/blog/lingui-vs-i18next/">
<link rel="alternate" hreflang="pl" href="https://muszarski.com/pl/blog/lingui-czy-i18next/">
<link rel="alternate" hreflang="x-default" href="https://muszarski.com/en/blog/lingui-vs-i18next/">

The codes are bare because the pages target languages, not countries; Google's format is an ISO 639-1 code with an optional region. x-default points at English. A page in one language gets no hreflang at all: my Polish-only post about Claude Code has nothing to pair with.

hreflang only pairs translations. Google "uses the visible content of your page to determine its language", not the lang attribute or the URL.

How do translated slugs work?

Pages share their slugs: /en/work/, /pl/work/, /es/work/. Blog posts may not: this post is multilingual-astro-site-with-lingui in English and wielojezyczna-strona-astro-z-lingui in Polish.

Each post has a translationKey in its frontmatter. At build time, translationsOf(post) maps the files that share the key from locale to path, and that map is the LocalizedPath the layout passes to alternates() and to the switcher. A new translation is one new file with the same key. Nothing else changes.

Where does the language switch take you?

The header switch
One click on the Polish flag: this post's Polish twin, not the Polish home page.

switchPath() tries three things in order: the same page in the language you picked, that language's version of a fallback section, then that language's home page. From the built pages:

You are on You pick You land on
/pl/work/ English /en/work/
/pl/blog/lingui-czy-i18next/ English /en/blog/lingui-vs-i18next/
/pl/blog/claude-code-po-polsku/ (Polish only) English /en/blog/
any blog post Español /es/ (there is no Spanish blog)

Google suggests links to the other versions so "users can click to choose". The switch is exactly that: plain links rendered at build time, with no JavaScript.

<a
  href={switchPath(path, to, fallback, locales)}
  hreflang={to}
  lang={to}
  aria-current={to === locale ? "true" : undefined}
>
  <span class={`flag flag-${to}`} aria-hidden="true" />
  <span class="sr-only">{AUTONYMS[to]}</span>
</a>

The flags are a CSS gradient and small inline SVGs, not emoji: Emojipedia notes that the Polish flag emoji "may show as the letters PL on some platforms". The real label is the language's own name (English, Polski, Español): screen-reader text and a tooltip in the header, visible next to the flag in the mobile menu. lang="pl" on "Polski" lets a screen reader say it in Polish, which is what WCAG 3.1.2 Language of Parts (Level AA) asks for.

How does Lingui run inside a static Astro build?

Short strings (buttons, headings, errors) are Lingui messages. In .astro files they work through lingui-for-astro, a community integration (0.7.1, not made by the Lingui team) that brings Lingui macros to Astro. A component writes t`Language` and gets the string for the page's locale.

The locale comes from middleware, which Astro runs "at build time for all prerendered pages":

export const onRequest = defineMiddleware((context, next) => {
  const param = context.params.locale;
  const locale = isLocale(param) ? param : DEFAULT_LOCALE;
  context.locals.locale = locale;
  setLinguiContext(context.locals, getI18n(locale));
  return next();
});

getI18n() returns one cached Lingui instance per locale. There is no global activate(): the build renders all three languages in one process, and a global active locale would be shared by every page being rendered.

Browser scripts never load Lingui. The contact form, for example, gets its translated error messages as a JSON attribute rendered at build time, so the page ships words, not a catalog. A new string goes through this loop:

  1. 01 Write English The English text goes in the component, inside a macro.
  2. 02 Extract bun run i18n:extract adds new messages to all three catalogs and drops unused ones.
  3. 03 Translate Polish and Spanish fill the new entries; Spanish is an AI translation.
  4. 04 Test Fails on a missing or empty string, a changed placeholder, a raw no-break space or a dash.
  5. 05 Build The Vite plugin compiles each .po on import and fails on a missing message.

The last gate is failOnMissing: "catalog": the build fails when the Polish or Spanish catalog lacks a message, checked before fallback locales apply. How that compares with i18next is in Lingui vs i18next.

One Polish detail: a one-letter word such as „w” or „z” shouldn't end a line. The catalogs hold plain spaces, and a typo(locale, text) helper swaps in no-break spaces at render time.

Where does long text live?

Not in Lingui. A 1,500-word post as one catalog message would be hard to translate and harder to review. Blog posts and legal texts are content collection files per language, in folders such as src/content/blog/pl/ and src/content/legal/es/.

Posts exist in English and Polish. The privacy policy and terms exist in all three, drafted in Polish (the Polish text prevails), and a test checks that their numbered sections match.

How is the Spanish version marked?

Spanish is a machine translation no fluent speaker has read yet, and the site says so:

  • The Spanish catalog's header carries X-Review-Status: unreviewed (AI translation), and a test fails if it disappears.
  • The footer of every Spanish page says, in Spanish, that the text was translated with AI and not yet reviewed by a native speaker.
  • The legal pages repeat it next to the clause on which language prevails.
  • There is no Spanish blog: on Spanish pages, the blog link goes to the English blog and says "(EN)".

The tool behind it is in Translating Lingui catalogs with an LLM. Google's spam policies name "automated transformations like synonymizing, translating" used to mass-produce pages "where little value is provided to users". A portfolio's handful of pages is not that scale. The real risk is quality: a stiff Spanish sentence argues against the care the rest of the site shows.

Limits

  • The sitemap only pairs pages with the same path. /pl/work/ lists its English and Spanish twins; a post with a different slug per language lists none, so its hreflang lives only in the page head. Google accepts that, but I'd rather feed the sitemap from alternates() too.
  • Flags stand for countries, not languages. English shows a UK flag for a mostly European audience; the language name carries the meaning.
  • lingui-for-astro is pre-1.0 and community-maintained. I keep .astro copy thin, so dropping it would cost a few explicit i18n._() calls.
  • The 404 page is English only for now.
  • Spanish stays "unreviewed" until a native speaker reads it.

To see the switch at work, open my work page and change the language: you stay on the same page.

Sources

  1. Internationalization (i18n) Routing (Astro Docs) (opens in a new tab) (accessed )
  2. Routing: dynamic routes and getStaticPaths (Astro Docs) (opens in a new tab) (accessed )
  3. Middleware (Astro Docs) (opens in a new tab) (accessed )
  4. @astrojs/sitemap (Astro Docs) (opens in a new tab) (accessed )
  5. Tell Google about localized versions of your page (Google Search Central) (opens in a new tab) (accessed )
  6. Managing multi-regional and multilingual sites (Google Search Central) (opens in a new tab) (accessed )
  7. Spam policies for Google web search (Google Search Central) (opens in a new tab) (accessed )
  8. Static configuration with vercel.json: redirects (Vercel Docs) (opens in a new tab) (accessed )
  9. Lingui: Vite plugin (opens in a new tab) (accessed )
  10. Lingui: CLI reference (opens in a new tab) (accessed )
  11. lingui-for: Lingui for Svelte and Astro (GitHub) (opens in a new tab) (accessed )
  12. Understanding SC 3.1.2: Language of Parts (W3C WAI, WCAG 2.2) (opens in a new tab) (accessed )
  13. Flag: Poland emoji (Emojipedia) (opens in a new tab) (accessed )

All posts

Article 6 min read

Translating Lingui catalogs with an LLM

How to machine-translate Lingui .po catalogs with an LLM, what breaks (placeholders, plurals, tone) and the checks to run before a catalog ships.