Internationalization (i18n)
Publish a site in more than one language — language-prefixed URLs, per-language navigation, translated UI strings, and automatic hreflang.
A juicer site can ship the same content in several languages. Each language
gets its own content tree, its own URL space, and its own navigation; pages
that exist in more than one language cross-link automatically, and the UI
chrome (button labels, “Previous”/”Next”, search placeholder, …) is
translated through dictionaries. Single-language sites pay nothing for any
of this — none of it activates until you declare a languages list.
This page documents every moving part. The juicer.build docs you’re reading are themselves bilingual (English + French), so it’s all dogfooded.
Turning it on
Declare the languages and the default in site.toml:
languages = ["en", "fr"]
defaultLanguage = "en"
defaultLanguageInRoot = true
languages— the list of language codes you publish. An empty or absent list means a single-language site (no behavior change at all).defaultLanguage— the fallback language. A missing translation (of a page or of a UI string) falls back to this language. Defaults to the first entry oflanguages; on a site with nolanguagesit defaults to"en".defaultLanguageInRoot— whentrue, the default language is served at the site root with no/<code>/prefix, and only the other languages get a prefix. Whenfalse(the default), every language is prefixed, including the default.
Content layout
Once languages is set, content lives one directory deeper — under
content/<lang>/:
content/
├── en/
│ ├── _index.md
│ └── getting-started/
│ ├── _index.md
│ └── installation.md
└── fr/
├── _index.md
└── getting-started/
├── _index.md
└── installation.md
A page’s language is the first path segment under content/. Two pages that
share the same path after the language segment — en/getting-started/installation.md
and fr/getting-started/installation.md — are translations of each other.
You don’t have to translate everything: a page that exists in only one
language simply has no translations, and nothing links to a missing one.
URLs
defaultLanguageInRoot decides the shape of the URL space. With the config
above (en default, in root):
| Page | URL |
|---|---|
content/en/getting-started/installation.md | /getting-started/installation/ |
content/fr/getting-started/installation.md | /fr/getting-started/installation/ |
The default language keeps the clean, prefix-free URLs — important when you
add a second language to an existing site and don’t want to break inbound
links. Set defaultLanguageInRoot = false to prefix every language
symmetrically (/en/… and /fr/…).
Building language-prefixed links by hand is error-prone. Use the
relLangURL / absLangURL helpers — they apply the
right prefix for you.
Per-page language data
Every page exposes its language and its translations on the rendering context:
.page.lang— the page’s language code (""on a single-language site)..page.translations— the list of the other-language versions of this page. Each entry carrieslang,title, andurl. Empty when the page has no translation..page.languages— every configured language (in declared order), each withlang, aurlto switch to, and acurrentflag. Theurlis the page’s own URL for the current language, its translation where one exists, and that language’s home otherwise — so the entry is never missing, even on an untranslated page. Empty for single-language sites.
.page.languages is what a full language switcher iterates — it always
offers every language, the way Starlight or Docusaurus do:
{{ if .page.languages }}
<details aria-label="{{ i18n .page.lang 'aria_language' }}">
<summary>{{ i18n .page.lang 'langname' }}</summary>
{{ for l <- .page.languages }}
<a href="{{ l.url }}" hreflang="{{ l.lang }}"{{ if l.current }} aria-current="true"{{ end }}>
{{ i18n l.lang 'langname' }}
</a>
{{ end }}
</details>
{{ end }}
The juicerdocs theme ships this as a dropdown in its topbar. (Use
.page.translations instead when you want a switcher that lists only the
translations that exist — handy for an inline two-language toggle.)
UI strings: the i18n helper
Chrome text — button labels, pager arrows, the search placeholder — is looked up by key instead of hard-coded, so it can be translated:
{{ i18n .page.lang 'nav_prev' }}
i18n takes a language code and a key. Lookup falls back in this
order:
- the requested language’s dictionary,
- the
defaultLanguage‘s dictionary, - the literal key (so a missing string is visible, not blank).
Because the language is the first argument, {{ i18n t.lang 'langname' }}
looks up a key in another language’s table — which is how the switcher
above shows each language’s own name.
Dictionaries
Strings come from i18n/<lang>.toml files — flat key = "value" tables:
# i18n/fr.toml# i18n/fr.toml
nav_prev = "Précédent"
nav_next = "Suivant"
Dictionaries are collected from two places and merged:
- the active theme(s) —
<theme>/i18n/<lang>.toml - your site —
<src>/i18n/<lang>.toml
with the same precedence as every other themed resource: your site wins over a theme, and an earlier theme in the lookup chain wins over a later (inherited) one. The merge is per-language and per-key, so a theme can ship a complete English chrome dictionary while your site adds only the handful of keys it overrides — or the extra languages it publishes.
This is why a theme like juicerdocs renders English chrome with zero
configuration: it ships its own i18n/en.toml. To translate the chrome,
drop an i18n/fr.toml into your site (or override individual keys); you
never have to fork the theme.
Language-aware navigation
The sidebar walks .site.root.subsections. On a multilingual site,
.site.root is scoped to the current page’s language — it resolves to
that language’s <lang>/_index.md. So a French page gets the French section
tree, an English page the English one, with no per-theme work. A section
that hasn’t been translated yet simply doesn’t appear in that language’s
navigation.
.page.prev / .page.next — the sequential pager — are scoped the same
way: navigation walks the current language’s reading order only, so the last
English page never points “next” into French.
Language-aware URLs
relLangURL and absLangURL are the language-aware companions of
relURL / absURL. They take the language as the first argument and prefix
the path with that language’s URL segment:
<a href="{{ relLangURL .page.lang '/getting-started/' }}">…</a>
On an English page (default, in root) this yields /getting-started/; on a
French page, /fr/getting-started/. Use these for fixed links in shared
layouts — like a hero call-to-action — so they stay within the reader’s
language. (.page.url and .page.translations[].url are already
language-correct; you only need relLangURL for paths you write by hand.)
Sitemap & hreflang
When a site is multilingual, sitemap.xml automatically gains
<xhtml:link rel="alternate" hreflang="…"> entries linking each page to its
translations, so search engines serve the right language. Single-language
sites get a plain sitemap with no hreflang noise. No configuration required
either way.
What stays zero-config
Everything on this page is dormant until languages is set. A site with no
languages list builds byte-for-byte as it did before i18n existed:
.page.lang is "", .page.translations is empty, the sitemap has no
hreflang, content stays at content/ (not content/<lang>/), and the
i18n helper still resolves theme dictionaries through the default-language
fallback so themed chrome renders in English.