Internationalization (i18n)
Build multi-language sites with language-aware routing and localized content.
Overview
Uniweb uses a Component Content Architecture (CCA) where content arrives to components already localized. There is no runtime translation lookup—each language is a complete static build.
Key characteristics:
- Language-prefixed URLs:
/,/es/,/fr/for different languages - Build-time translation: Translations are merged during build, not runtime
- Full page reload for switching: Navigating to
/es/aboutloads Spanish content - SEO-friendly: Each language generates separate static HTML with proper
hreflangtags
Quick Start
1. Configure Languages
# site.yml
defaultLanguage: en
languages: [en, es, fr]2. Build Your Site
uniweb build3. Extract Translatable Strings
uniweb i18n extractThis scans your built content and generates locales/manifest.json with all translatable strings keyed by content hash.
4. Initialize Language Files
uniweb i18n init es frCreates locales/es.json and locales/fr.json pre-populated with all manifest keys. Values default to source text so translators can see what to translate.
5. Translate
Edit the generated language files, replacing source text with translations:
{
"a1b2c3d4": "Bienvenido a Nuestra Empresa",
"e5f6g7h8": "Aprende Más"
}6. Build with Translations
uniweb buildThe build merges translations and generates language-specific output.
Configuration
Basic Setup
# site.yml
defaultLanguage: en
languages: [en, es, fr]The default language has no URL prefix (/about), while other languages get prefixed (/es/about, /fr/about).
Language Names
A language is named by its code — list codes, not labels:
defaultLanguage: en
languages: [en, es, fr]Language switchers show each language in its own name: English, Español, Français. Common languages come from a built-in table, and any other code is named by the platform (ca is Català, eu is Euskara), so a site never writes a label. The older { code, label } entries are an error: a label cannot travel when the site is pushed to a backend, so a site that used them showed different names depending on where it was served.
Auto-Discovery
defaultLanguage: en
languages: '*'Automatically discovers languages from the locales/ folder. Any .json file in the locales directory (other than manifest.json and _memory.json) is treated as a language.
The default language belongs in languages
List it, alongside the others:
defaultLanguage: en
languages: [en, fr]Its pages are built at the site root, not under dist/en/ — the root tree is the default language, and hreflang="x-default" already points there. So listing it changes what the site declares, never what it emits: a language switcher needs it as a choice, and a site that never names it can end up with no switcher at all.
A URL that carries the default language's code anyway — /en/about — names the same page as /about, and a visitor who follows one is sent there. A host that answers with a status sends a permanent redirect (301).
Declare it even if translations are auto-discovered. The build finds locales on disk, but a page's links are resolved at render from what the site declares. A site with
locales/fr.jsonand nolanguages:builds a complete French tree whose every internal link points back into the default-language tree — the pages exist and nothing reaches them. Recent versions stamp the resolved set onto the build output so this can't happen silently; declaring it is still the clearer form. Auniweb pullwrites the list your site has, so a site that declares none gets one.
Custom Locales Directory
i18n:
localesDir: translations # Default: localesDraft Languages
Adding a language to languages makes it part of your working set — but a half-translated language shouldn't ship. publishLanguages declares which declared languages a published build includes:
defaultLanguage: en
languages: [en, es, fr] # working set — all previewable in dev
publishLanguages: [en, es] # fr is a draft: built in dev, excluded from productionWhen the field is absent, every declared language is published — existing sites are unaffected. With the field present:
- Production builds skip draft locales entirely: no
dist/fr/output, no language-switcher entry, no sitemap orhreflangreferences. uniweb devrenders drafts normally — the list only affects published output.- The default language must be in the list; an empty or contradictory list fails the build.
- All
uniweb i18ncommands (extract, generate, status) keep covering the full declared set, so translation work on drafts proceeds as usual. - A code left in
publishLanguagesafter its language is removed fromlanguagesis harmless — it warns at build and re-activates if the language is declared again.
When the translation is ready, add the language to publishLanguages and rebuild.
How Content Arrives to Components
Components receive content already translated. When a user visits /es/about, the component receives Spanish content in content.title, content.paragraphs, etc.—there's no translation function to call.
// Component receives localized content directly
function Hero({ content }) {
// content.title is already in the user's language
// No t() function, no translation lookup
return (
<section>
<h1>{content.title}</h1>
{content.paragraphs.map((p, i) => <p key={i}>{p}</p>)}
</section>
)
}This is the Component Content Architecture (CCA)—content is resolved before it reaches components.
Generated Routes
For a site with pages home and about:
| Page | English (default) | Spanish | French |
|---|---|---|---|
| Home | / | /es/ | /fr/ |
| About | /about | /es/about | /fr/about |
The default language never has a prefix. Other languages always have their code as a prefix.
Localized URL Segments
By default the path after the language prefix matches the default language (/about → /es/about). To give the path a native segment per language, add slug: to the page's page.yml:
# pages/about/page.yml
slug:
es: acerca-de
fr: a-propos| Page | English (default) | Spanish | French |
|---|---|---|---|
| About | /about | /es/acerca-de | /fr/a-propos |
- The folder name stays the page's stable canonical route (default language + internal
page:links); list only the non-default languages. - Each value is a single path segment (no slashes). Nested folders compose automatically — localize a parent (
/blog→/blogue) and its children follow (/blog/post→/blogue/post). - Localized segments flow through navigation,
website.getLocaleUrl()(the language switcher below), and thehreflangtags. - One URL per page per language: the default language's segment under another prefix —
/fr/about, where French shows/fr/a-propos— sends the visitor to/fr/a-propos. A host that answers with a status sends a permanent redirect (301), as for/en/about.
See Page Configuration → Localized URLs for the full reference.
Language Switcher
Building a Switcher
import { useWebsite, Link, getLocaleLabel } from '@uniweb/kit'
function LanguageSwitcher() {
const { website } = useWebsite()
// Only show if site has multiple languages
if (!website.hasMultipleLocales()) {
return null
}
const active = website.getActiveLocale()
return (
<nav>
{website.getLocales().map(locale => (
<Link
reload
key={locale.code}
href={website.getLocaleUrl(locale.code)}
className={locale.code === active ? 'active' : ''}
>
{getLocaleLabel(locale)}
</Link>
))}
</nav>
)
}Use
<Link reload>rather than a plain<a>orwindow.location.href.getLocaleUrl()returns a root-relative path that does not include the deployment base path. On a site deployed under a subdirectory (base: /docs/insite.yml), assigning it directly to anhrefor towindow.locationnavigates outside the site.<Link reload>prependswebsite.basePathand renders a plain<a>, giving the full page load a language switch needs — a normal SPA<Link>would not, because the other language is a separate build.If you need a
<select>instead of links, prepend the base path yourself:window.location.href = website.basePath.replace(/\/$/, '') + website.getLocaleUrl(code).
Website Locale API
import { useWebsite } from '@uniweb/kit'
function MyComponent() {
const { website } = useWebsite()
// Check if site is multilingual
website.hasMultipleLocales() // boolean
// Get all languages
website.getLocales()
// [{ code: 'en', label: 'English', isDefault: true }, ...]
// Get current language
website.getActiveLocale() // 'en'
// Get URL for switching languages (from current page)
website.getLocaleUrl('es') // '/es/about' (if currently on /about)
}Display Names Utility
The name a switcher shows for a code:
import { getLocaleLabel, LOCALE_DISPLAY_NAMES } from '@uniweb/kit'
getLocaleLabel('es') // 'Español' — the built-in table
getLocaleLabel('ca') // 'Català' — the language's own name, where the platform knows it
getLocaleLabel('xx') // 'XX' — the code, when nothing names it
// The built-in names
console.log(LOCALE_DISPLAY_NAMES.fr) // 'Français'Translation Workflow
Uniweb uses a hash-based translation system for managing translations at scale.
Extract Strings
uniweb i18n extractGenerates locales/manifest.json with all translatable content:
{
"version": "1.0",
"defaultLanguage": "en",
"extracted": "2025-01-29T...",
"units": {
"a1b2c3d4": {
"source": "Welcome to Our Company",
"field": "title",
"contexts": [{ "page": "/about", "section": "intro" }]
},
"32476724": {
"source": "That's controlled by your role. See Roles and permissions.",
"markup": "That's controlled by your role. See [Roles and permissions](page:docs/roles).",
"field": "paragraph.12",
"contexts": [{ "page": "/docs/reference/faq", "section": "body" }]
}
}
}One unit per block element — a heading, a paragraph, a list item — never a fragment of one, and never two joined. A \n inside a source is a line break within one paragraph.
source is the key; markup is the prompt. The hash is computed over source, the element's flattened plain text, so adding a link or bolding a phrase does not move the hash and does not orphan the existing translation. markup is that same element as inline Markdown, and appears only when the element carries formatting the plain text has lost. It is what uniweb i18n init seeds a language file with, and what a translator should work from — except where markupVaries says the string's places differ in formatting, when init seeds its plain words (see The same words in several places, below).
The contexts array shows where the string appears, and field indicates the content type (title, paragraph, link label, etc.).
Why both. A translation value is parsed as inline Markdown at merge time, so the value channel carries links and marks losslessly. Before
markupexisted, the source channel did not: a translator was shown the flattened text and could only produce flattened text. A fully translated site would report 100% coverage and render with every inline link gone.
Options:
--verbose— Show extracted strings in output--records-only— Extract only record content (skip pages)--no-records— Skip records (pages only)
Initialize Language Files
uniweb i18n init es frCreates starter language files pre-populated with all manifest keys. By default, values are set to the source text so translators can see what needs translating.
Behavior:
- File doesn't exist: Creates it with all manifest keys
- File exists (no
--force): Merges — adds only missing keys, preserves existing translations - File exists with
--force: Overwrites entirely
Options:
--empty— Use empty strings instead of source text as placeholder values--force— Overwrite existing files entirely
If no locale codes are specified, initializes all languages configured in site.yml.
# Create files with source text as placeholders
uniweb i18n init es fr
# Create files with empty values (useful for translation tools)
uniweb i18n init --empty
# Overwrite existing files from scratch
uniweb i18n init es --forceTranslate
Edit language files, replacing source text (or empty strings) with translations:
{
"a1b2c3d4": "Bienvenido a Nuestra Empresa",
"e5f6g7h8": "Aprende Más"
}A value is inline Markdown. At build time the merge parses it and replaces the element's inline content with the result — it does not re-apply the source's marks (buttons aside, below), and there is no placeholder or tag convention. So links, bold and emphasis in a sentence have to be written into the translation:
{
"32476724": "Cela dépend de votre rôle. Voir [Rôles et permissions](page:docs/roles)."
}A plain-text value for such a sentence produces a plain-text paragraph. page: references, root-relative paths and full URLs are all carried through verbatim as hrefs.
An element made only of links is the exception — a button, or buttons on consecutive lines (one paragraph, so one string). A value with no link of its own is their labels: the whole value for one link, a line each for several. Each keeps its source link, target and options ({role=primary icon=…}) included:
{
"12133946": "Créer un groupe",
"583e9a06": "Soutenez notre travail\nEn savoir plus"
}A value that writes a link is taken as written, so a translation can still re-target a button. Labels that don't match the links one per line are taken as written too, and lose their links. A uniweb pull writes a button back the same way: its labels, or the link written out where this language sends it somewhere else.
The same words in several places are one string. A heading that says Faculty of Science and a link that says [Faculty of Science](/) share one translation. When a string's places differ like this, the manifest marks it "markupVaries": true and uniweb i18n init starts it with its plain words. Translate it with the words alone: the heading takes them as they are, and the link — like a button — takes them as its label and keeps its target. A link you write goes to every place the string appears, so write one only when each place should have it; to give one place its own wording, use an override (below). A page title or a navigation label is plain text, and takes a translation's words without its formatting.
uniweb i18n status reports the entries where this has been lost:
Loses markup: 12 (links/bold in the source, plain in the translation)⚠️ Coverage cannot see this class — a flattened translation still counts as translated — so check that line as well as the percentage.
For strings that appear in multiple contexts and need different translations, use the override format:
{
"e5f6g7h8": {
"default": "Aprende Más",
"overrides": {
"/pricing:cta": "Ver Precios"
}
}
}Override keys use the format {page}:{section}.
Tips:
- Use
uniweb i18n status --missing --jsonto export untranslated strings for translation tools - The manifest's
contextsarray helps translators understand where each string appears - Group your translation effort by page using
uniweb i18n status --missing --by-page
Build
uniweb buildThe build merges translations and generates one site-content.json per language:
dist/
├── index.html # English (default)
├── site-content.json # English content
├── es/
│ ├── index.html # Spanish
│ └── site-content.json # Spanish content
└── fr/
├── index.html # French
└── site-content.json # French contentSync Changes
After content updates, sync the manifest to detect additions, removals, and changes:
uniweb i18n syncReports what changed (new strings, removed strings, modified strings). Use --dry-run to preview without writing changes.
It also reports remarked strings — an element whose words are unchanged but whose inline Markdown is not, typically an author adding a link to an existing sentence:
✎ 3 strings kept their words but changed inline markup (links/bold)
Existing translations still count as valid — update them or the markup is lost.The hash cannot detect this on its own — that is the point of keying on the flattened text — so without this line an added link would silently never reach any translation.
Translation Status and Auditing
Status
Check translation coverage for all languages or a specific one:
uniweb i18n status # All languages
uniweb i18n status es # Spanish only
uniweb i18n status --json # Machine-readable outputAdditional options:
--missing— List all untranslated strings instead of just a summary--missing --by-page— Group missing strings by page--missing --json— Export missing strings as JSON (useful for translation tools or AI translation)--freeform— Show free-form translation status (staleness, orphans)
Audit
Find stale translations (hashes no longer in manifest) and missing ones:
uniweb i18n audit # Show stale and missing entries
uniweb i18n audit es # Audit Spanish only
uniweb i18n audit --clean # Remove stale entries from language files
uniweb i18n audit --verbose # Show detailed outputFree-Form Translations
For sections that need complete content replacement (different structure, images, or layout per language) rather than string-by-string translation, use free-form translations.
Overview
Free-form translations replace an entire section's content with a language-specific markdown file. This is useful for:
- Content that needs different structure per language
- Sections with language-specific images or media
- Marketing copy that should be rewritten, not translated
File Structure
locales/
├── manifest.json
├── es.json
├── freeform/
│ └── es/
│ ├── .manifest.json # Staleness tracking (auto-managed)
│ ├── pages/about/hero.md # By page route
│ ├── page-ids/installation/intro.md # By page ID
│ └── records/article/getting-started.mdInitialize a Free-Form Translation
uniweb i18n init-freeform es pages/about hero
uniweb i18n init-freeform es page-ids/installation intro
uniweb i18n init-freeform es records/article getting-startedCreates a markdown file pre-populated with the source content, ready for translation. Also records a source hash for staleness detection.
Staleness Detection
When the source content changes, free-form translations become stale. Check status with:
uniweb i18n status --freeformThe free-form commands read your site's source files, so there's no need to build first. A record's translation is listed as not checked: nothing compares it with its record yet.
After reviewing changes, update the recorded hash:
uniweb i18n update-hash es pages/about hero # Specific section
uniweb i18n update-hash es --all-stale # All stale at onceMove and Rename
When pages are reorganized:
uniweb i18n move pages/docs/setup pages/getting-started
uniweb i18n rename pages/about hero welcomeThese commands update all languages and their manifests.
Prune Orphaned Translations
Remove free-form page translations whose section no longer exists — judged by the same paths the site reads translations from, so a translation a page still renders is never removed:
uniweb i18n prune --freeform --dry-run # Preview what would be removed
uniweb i18n prune --freeform # Remove orphaned filesRecord translations (records/…) are never pruned. Neither are the translations of a page or section marked hidden: true: it's a draft, left out of the published site but still there.
Records i18n
Record data is translated alongside page content by default. The extract command processes both pages and all record JSON — whether generated from records/ or hand-written.
Extract Record Strings
uniweb i18n extract # Pages + records (default)
uniweb i18n extract --records-only # Records only
uniweb i18n extract --no-records # Pages only
uniweb i18n status --records-only # Coverage of the record translations
uniweb i18n audit --records-only # Stale and missing record translationsRecord strings are stored in a separate manifest at locales/records/manifest.json, keyed by the record (std/article/habitat-protection) rather than by any query that returns it.
A record whose query has a data schema is translated as its schema says: its text and rich-text fields are, and a field that is an enum, a URL or an email, a number, a date, a file, a reference, or marked translatable: false is not — the same fields a push to a backend carries in each language. A referenced record's card, shown inside another record, is translated as that record is. An excerpt the build derives from a record's body is derived again in each language from the translated body, so it is never a string to translate. A record with no data schema is read by a heuristic, or by a companion .schema.js file.
A tagged data block follows the same rule when its section's component declares the shape of the block's key in meta.js data: — a data schema ('@/member') or an inline field map. Its text fields are translated and nothing else:
// meta.js — the logos' names are text; a style names one of the component's looks
data: { logos: { name: 'string', style: { type: 'string', enum: ['display-black', 'serif-italic'] } } }A key declared with no shape (logos: {}) leaves its blocks to a heuristic. It skips fields such as href, icon and slug and values such as URLs and colors, and offers every other string — a style: display-black included, which a translated file would turn into a look the component does not have. Declare the shape of any block a translator will see.
A record's system fields — $name and every other $ key — are never extracted. The file a query writes for each record — the record whole — is translated with the same strings as the list, so a translated record page shows its translated body.
Context-Specific Overrides
When the same source string appears in multiple places and needs different translations depending on context, use the override format:
{
"e5f6g7h8": {
"default": "Learn More",
"overrides": {
"/pricing:cta": "See Pricing",
"/about:intro": "Discover Our Story"
}
}
}The default value is used everywhere except the specified overrides. Override keys use the format {page}:{section} matching the contexts in the manifest: the page's route, and the section's id — its file name without a number prefix (2-cta.md is cta), or the id: in its frontmatter. The id stays the same when files are renumbered, and an override survives pushing the site to a backend and pulling it back.
A page's own title, label, description, keywords, seo.ogTitle and seo.ogDescription use the section id _meta — "/about:_meta". The site's own seo.ogTitle and seo.ogDescription, in site.yml, use "/:_site". In locales/records/, an override names a record by the identity the records manifest lists in its contexts — "std/article/habitat-protection".
SEO Considerations
Automatic hreflang Tags
The build generates proper hreflang tags for each page:
<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" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about" />HTML lang Attribute
Each language's HTML includes the correct lang attribute:
<html lang="es">Search Index
With i18n enabled, separate search indexes are generated per language:
dist/
├── search-index.json # English
├── es/
│ └── search-index.json # Spanish
└── fr/
└── search-index.json # FrenchThe search client automatically uses the correct index for the active language.
CLI Reference
| Command | Description |
|---|---|
uniweb i18n extract | Extract translatable strings to manifest.json |
uniweb i18n init [locales] | Generate starter language files from manifest |
uniweb i18n sync | Update manifest with content changes |
uniweb i18n status [locale] | Show translation coverage |
uniweb i18n audit [locale] | Find stale and missing translations |
uniweb i18n init-freeform <locale> <path> <id> | Create free-form translation |
uniweb i18n update-hash <locale> [<path> <id>] | Update hash after source changes |
uniweb i18n move <old> <new> | Move free-form translations |
uniweb i18n rename <path> <old-id> <new-id> | Rename free-form translation |
uniweb i18n prune --freeform | Remove orphaned free-form translations |
| Option | Applies to | Description |
|---|---|---|
-t, --target <path> | All | Specify site directory |
--verbose | extract, sync, audit | Detailed output |
--dry-run | sync, prune | Preview without writing |
--empty | init | Empty strings instead of source text |
--force | init | Overwrite existing files |
--clean | audit | Remove stale entries |
--missing | status | List missing strings |
--by-page | status --missing | Group by page |
--freeform | status, prune | Free-form translation mode |
--json | status | Machine-readable output |
--records-only | extract, status, audit | Record strings only (locales/records/) |
--no-records | extract | Skip records (pages only) |
--all-stale | update-hash | Update all stale hashes |
Best Practices
- Write in default language first: Complete content in your default language, then extract and translate
- Use
initfor new languages: Rununiweb i18n initafter extracting to get pre-populated files - Use descriptive contexts: The manifest shows where each string appears—use this to provide accurate translations
- Test all languages: Check that layouts work with longer/shorter text in different languages
- Keep translations in sync: Run
uniweb i18n syncafter content changes to update the manifest - Use
--missing --jsonfor AI translation: Export untranslated strings for batch translation with AI tools - Consider RTL: For Arabic, Hebrew, etc., you may need additional CSS for right-to-left layout
See Also
- Translating Your Site — Content author guide (no coding required)
- Site Configuration — i18n settings in site.yml
- Content Structure — How content flows to components
- Site Search — Language-specific search indexes