Internationalization
Serve your docs in multiple languages with locale-aware routing, per-language navigation, translated UI, and SEO — all convention-first.
Blume serves one project in many languages. Drop translated files into the right place and Blume wires up routing, the language switcher, per-locale navigation, and SEO for you — there’s no separate routing layer to maintain. It’s opt-in: without an i18n block, your site stays single-language exactly as before. It also composes with versioning — a frozen snapshot keeps its translations, and locale fallback works within each version.
Enable it
Add an i18n block listing your locales and which one is the default:
i18n: {
defaultLocale: "en",
locales: [
{ code: "en", label: "English" },
{ code: "fr", label: "Français" },
{ code: "ar", label: "العربية", dir: "rtl" },
],
}
Each locale has a code (used in URLs), a label (shown in the language switcher), and an optional dir for right-to-left scripts ("ltr" by default). An optional style gives blume translate freeform guidance for the locale — register, dialect, terminology, e.g. "Brazilian Portuguese, informal você" — so the choice is pinned from the very first translation instead of decided by the agent.
Organize translated content
The default locale lives at your content root. Every other locale is a top-level folder named by its code, mirroring the default structure:
docs/
index.mdx -> /
guides/quickstart.mdx -> /guides/quickstart
fr/
index.mdx -> /fr
guides/quickstart.mdx -> /fr/guides/quickstart
ar/
index.mdx -> /ar
| File | Route |
|---|---|
docs/index.mdx |
/ |
docs/guides/quickstart.mdx |
/guides/quickstart |
docs/fr/guides/quickstart.mdx |
/fr/guides/quickstart |
Don’t give the default locale a folder of its own: only the other codes are locale folders, so docs/en/ with an en default is ordinary content that publishes at /en/… (and at /fr/en/… as a French fallback). Blume warns when it finds one.
You only translate the files you want — everything else falls back automatically (see Fallbacks).
Filename suffixes
Prefer to keep translations next to the original? Set parser: "dot" and name files with a locale suffix instead of using folders:
docs/
guides/quickstart.mdx -> /guides/quickstart (default)
guides/quickstart.fr.mdx -> /fr/guides/quickstart (French)
Good for sparse translations — colocate the few pages you’ve translated without mirroring the whole tree.
Shared files
For content that’s the same in every language — a changelog, a status page — add a $ marker so one file serves all locales without duplication:
docs/changelog.$.mdx -> /changelog and /fr/changelog (same content)
docs/guides/meta.$.ts (folder meta applied to every locale)
A locale-specific meta.ts still overrides the shared one for that language.
Default-locale URLs
By default the default locale has no URL prefix (/, /guides/quickstart) while other locales are prefixed (/fr/…). This keeps your primary language’s URLs clean. To prefix every locale, including the default:
i18n: {
// …
hideDefaultLocalePrefix: false, // /en/…, /fr/…
}
Per-locale navigation
Each language gets its own sidebar, built from that locale’s files — so translations can diverge in structure, ordering, or labels. Folder meta.ts files resolve per locale, too: under the default dir parser, put a meta.ts under fr/guides/ to order the French group independently. Until a locale has one (or a shared meta.$.ts), its group mirrors the fallback locale’s — that folder’s meta.ts and the sidebar.display its index page sets — so pages that fall back keep the same titles, order, and collapsible groups. Under the dot parser translations sit next to the originals, so a folder’s meta.ts applies to every locale. Everything else about navigation works the same, per language.
Header tabs, header links, and the footer are configured, not derived from content, so their labels localize in blume.config.ts. Every configured label accepts a per-locale map ({ en: "Docs", fr: "Documentation" }) alongside the plain-string form, falling back to the default locale’s entry for locales you haven’t filled in: tab and dropdown labels (Tabs), featured links, header actions and the call to action, footer links, the banner’s text and link text, and custom clients in the Connect to MCP menu.
navigation: {
cta: {
href: "https://acme.dev/signup",
label: { en: "Start free", fr: "Essai gratuit" },
},
},
footer: {
links: [
{ label: { en: "Pricing", fr: "Tarifs" }, href: "https://acme.dev/pricing" },
],
},
Tab paths, header and footer links, and the header logo’s link move into the reader’s locale too, whenever that locale serves the route, so the chrome stays inside one language. A route only the default locale serves — a custom page or the generated changelog index — keeps its own path instead of pointing at a localized URL that would 404.
Fallbacks
When a page isn’t translated yet, Blume renders the fallback locale’s content at the localized URL — so the link works, the page is fully pre-rendered, and search engines aren’t sent to a dead end. The fallback defaults to your defaultLocale:
i18n: {
// …
fallbackLocale: "en", // default; set to null to 404 instead
}
Fallback pages point their canonical link at the page they copy, are left out of the search index, llms.txt, and the MCP and JSON API page lists, and aren’t advertised as real translations in hreflang, so untranslated content doesn’t compete for ranking. They still appear in that locale’s sidebar, so navigation stays complete — a reader can reach every page in any language.
Release notes from a githubReleases() source are the exception: they’re published in one language, so they’re never copied to other locales’ URLs and show no language switcher.
Links across locales
Write internal links the way you would in the default locale — [Setup](/guides/setup), <Card href="/guides/setup"> — in every language, including translated pages. When a page renders under a locale prefix, Blume moves each root-relative page link into that locale (/fr/guides/setup) as long as the route is served there, as a real translation or as a fallback page. A link with no per-locale variant — a custom page, a generated route, or a missing translation on a site with fallbacks disabled — keeps its authored target rather than pointing at a 404, and a link that already carries a locale prefix (/de/guides/setup) is left alone, so cross-locale links stay explicit.
Anchors travel with the link, so heading ids have to agree across languages. blume translate takes care of that: every translated heading is pinned to its source heading’s id with a trailing [#id] marker. In a translation you write by hand, pin the headings yourself with the same [#custom-id] marker — otherwise #ordering won’t match the French page’s auto-generated #ordre, and blume validate reports the mismatch against the translated page a reader actually lands on.
Translating with an agent
You don’t have to fill in the locales by hand. blume translate finds every page that’s missing or outdated in each locale and translates it with a local agent CLI (Codex or Claude Code):
blume translate --codex
Blume validates each result’s structure — frontmatter, code fences, links — and writes the files itself; the agent only translates text. A committed ledger (blume.translations.json) tracks which source revision each translation came from, so reruns only touch what changed, and translations you wrote by hand are adopted as-is, never overwritten. In CI, blume translate --check fails when a source page has drifted ahead of its translations.
The language switcher
When i18n is on, a language switcher appears in the header automatically, generated from your locales. For each page it links the matching translation in every language; where a translation is missing it links the fallback page and marks it as not translated. With fallbacks off (fallbackLocale: null) there’s no page to link, so the switcher leaves that language out. There’s nothing to configure. Pages from a GitHub Releases source are the exception: release notes exist in one language only, so those pages render without a switcher.
Routing by browser language
To send visitors to their own language, turn on routeByBrowserLanguage:
i18n: {
defaultLocale: "en",
locales: [
{ code: "en", label: "English" },
{ code: "fr", label: "Français" },
],
routeByBrowserLanguage: true,
}
A visitor who lands on the default language’s home page from outside the site then goes to the home page in their browser’s preferred language, when the site has it. Blume tries each language the browser prefers, in order, matching an exact code first and then the base language (fr-CA finds fr). If the default language comes first, the visitor stays. Once a reader picks a language with the switcher, Blume remembers the pick and stops routing them. Only the home page routes; a link to any other page opens as written.
The redirect runs in the browser before the page paints, so it works on any host, static builds included. It’s off by default because search engines crawl in one language: with it on, keep the hreflang alternates Blume emits so each language’s pages are still indexed.
Translated UI
Blume ships built-in translations for its own interface chrome — “On this page”, “Search”, “Edit on GitHub”, and the rest — so a locale with a built-in pack gets translated UI out of the box. You only translate your content.
Packs ship for over 30 languages — Arabic, Bengali, Bulgarian, Catalan, Chinese (Simplified and Traditional), Croatian, Czech, Danish, Dutch, Finnish, French, German, Greek, Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Norwegian, Persian, Polish, Portuguese (and Brazilian Portuguese), Romanian, Russian, Serbian, Slovak, Spanish, Swedish, Thai, Turkish, Ukrainian, and Vietnamese. They’re community-maintained — open a PR to add a locale or sharpen a translation.
Missing or unshipped strings fall back to the default locale, then to English. To override a string or supply your own language, set i18n.ui, keyed by locale:
i18n: {
// …
ui: {
fr: {
search: { button: "Rechercher", placeholder: "Rechercher…" },
page: { previous: "Précédent", next: "Suivant" },
},
},
}
SEO
Localized SEO is handled for you — no per-page metadata to write:
<html lang>anddirare set from the active locale.hreflangalternates link every real translation of a page that search engines may index (anoindex, hidden, or canonical-elsewhere translation is left out), plus anx-defaultpointing at the default locale.- Canonical URLs are locale-correct, and JSON-LD carries
inLanguage.
Set deployment.site so these can be emitted as absolute URLs.
Search
Search is scoped to the active language: on a /fr/… page the dialog returns French results, with an All languages toggle to search across every locale at once. The default (Orama) and FlexSearch indexes filter in the browser, Pagefind keeps an index per language, and Algolia, Orama Cloud, and Typesense carry a locale field on each record. Mixedbread can’t scope by language, so with it every search spans every locale and the dialog has no toggle. See Search for how each adapter handles it.
Right-to-left
Set dir: "rtl" on a locale and Blume mirrors the whole interface — the sidebar, header, table of contents, pagination, search, and menus — and sets <html dir> to match. Two things deliberately stay left-to-right: code blocks (code reads LTR in any language) and fallback content — an untranslated page keeps the direction of the language it’s actually written in, so English shown under an RTL locale still reads correctly while the surrounding chrome mirrors.