Adding a New Locale
A Reepolee project ships with whatever locales you need - there's no "framework default" beyond the locales you declare. Adding one means registering the BCP 47 locale code, seeding its translations, and optionally localising URLs; the system picks it up automatically. Translations cascade across locales so a partial translation is never broken; missing keys fall back to a locale that has them.
Translations are DB-first. The
translationsdatabase table is the source of truth. The Add locale tool copies the default-locale rows for the new locale straight into the DB and can AI-translate them in place. See Translations for the full model.
This recipe walks through adding French to a project that already has English and Slovenian. The same steps work for any BCP 47 locale code your project needs - fr-FR, de-AT, es-ES, and so on.
The Fast Path - bun reeman
The interactive setup tool wraps the whole add-a-locale workflow:
bun reeman
# pick "Add locale" → enter "fr-FR" → answer "y" to AI-translate
What it does in one shot:
- adds the code to
locales,active_locales, andlocale_namesinconfig/supported_locales.ts - reads every default-locale row from the
translationstable and inserts a copy for the new locale across all namespaces, so the new locale starts fully populated (with default-locale values as placeholders) - updates the
ui.locale_names/ui.language_names_toentries for every existing locale so the new locale shows up in their pickers - when you opt in to AI translation, runs the one configured AI provider and translates the new rows in the database. Configure exactly one of Ollama, Gemini, OpenAI, Claude, xAI Grok, Hugging Face, or OpenRouter - see Dynamic Translations → Choosing a Provider
The rest of this page walks through the underlying pieces, plus the URL-localisation and locale-mismatch dialog details. With the reeman fast path you can skip straight to Step 4: Localise URLs.
Step 1: Register the Locale
Open config/supported_locales.ts and add "fr-FR" to both locales and active_locales:
export const locales = ["en-US", "sl-SI", "fr-FR"] as const;
export const active_locales = ["sl-SI", "en-US", "fr-FR"] as const;
export const default_locale = "en-US";
export const locale_names: Record<string, string> = {
"en-US": "EN",
"sl-SI": "SL",
"fr-FR": "FR",
};
export const locale_aliases: Record<string, string> = {};
The two arrays serve different purposes:
locales- every locale loaded from thetranslationstable.active_locales- the locales the picker shows users. Usually equal tolocales, but you can keep a locale out of the picker while you're translating it (setactive_localesto["sl-SI", "en-US"]andlocalesto["sl-SI", "en-US", "fr-FR"]; French strings load but no UI offers French to users).
locale_names is what the picker displays. locale_aliases points one locale at another's UI strings when you want to serve the same strings to several regions (e.g. "de-AT": "de-DE") - see Locales.
After this change, add French rows to the translations table. The reeman flow does this automatically; if you edit the config manually, run the add-locale generator next.
Step 2: Create Root Translations
The add-locale generator copies the default-locale translation rows for French. If you registered the locale by editing the config, run it explicitly:
bun reeman add-locale fr-FR
Edit the copied rows in /system/translations, or use SQL. For example, root values use the root namespace:
UPDATE translations
SET translation = 'Enregistrer'
WHERE locale = 'fr-FR' AND namespace = 'root' AND key_path = 'labels.save';
Keep the same key paths as the default locale. The route_name key is reserved for URL segment localisation and belongs in the relevant route namespace (see Step 4 below).
Restart the dev server. Visit /?locale=fr-FR and the labels appear in French - the locale switcher in the layout now offers French alongside Slovenian and English.
Step 3: Translate Route-Specific Strings
Every namespace that has default-locale and Slovenian rows needs French rows for full coverage. The Add locale reeman does this for you when you add the code (above): it copies every default-locale row in the translations table to the new locale so French starts fully populated with default-locale values as placeholders. If you registered the locale by editing the config by hand, run the same step explicitly:
bun reeman add-locale fr-FR # copy en-US rows → fr-FR in the DB
bun reeman add-locale fr-FR --translate # …and AI-translate them in place
At this point French exists for every key, but the values are still English. Fill them in by editing rows through the /system/translations admin UI, or let the AI pass do the first draft (next).
Restart the server, visit /login?locale=fr-FR, confirm the strings render - they'll read in English until translated, never as blanks or braced key paths.
Auto-Translating Missing Keys
For the first pass on many strings, let an LLM fill in the gaps. sync-translations scans the translations table for keys present in one locale but missing (or still untranslated) in another, translates them, and writes the results back to the database:
bun reeman sync-translations --translate
This uses whichever AI provider your environment selects - Ollama, Gemini, OpenAI, Claude, xAI Grok, Hugging Face, or OpenRouter (Choosing a Provider). The run sends each key with the source locale value as context to the model and writes the translation to the DB. Output cost is cheap - translating an entire mid-sized application's strings runs to a few cents.
Always review LLM-translated strings before shipping (the /system/translations admin UI is the place to do it). The model gets nuance right most of the time but occasionally produces overly literal phrasing or mistranslates UI conventions ("Submit" as a verb vs as a button label, for example).
Step 4: Localise URLs
Reepolee translates URL segments through the route_name key in the translations table. The canonical /login URL becomes /connexion in French if you add:
INSERT INTO translations (locale, namespace, key_path, translation)
VALUES ('fr-FR', 'login', 'route_name', 'connexion');
Now /login is also reachable as /connexion in French. The handler is the same; the URL is an alias. For URLs mounted under a prefix (e.g. /system/users), translate each parent segment via its own route_name so the full path localises (/sistem/uporabniki).
The route map is built at startup, so restart the server to pick up new route_name translations. Confirm by:
- Visiting
/connexiondirectly - the login form renders in French (because the URL implies French). - Switching to French via
?locale=fr-FRfrom any page - the browser redirects to the French version of the canonical URL, including the localised path.
route_name is the only translation key that doesn't fall back across locales. If you don't translate the URL for a specific feature, the canonical segment is used. That's intentional - it lets you ship a fully-translated UI before translating any URLs and have everything work.
Step 5: The Locale-Mismatch Dialog
A French-speaking user (cookie says locale=fr-FR) might land on /login (the English URL) by clicking a link in an email or sharing. To avoid silently switching locales, the layout includes a dialog that detects the mismatch and offers to switch.
The dialog uses the user's preferred locale (French in this case) for its own text - so the offer is comprehensible. Add its keys in the root namespace through /system/translations or SQL:
INSERT INTO translations (locale, namespace, key_path, translation) VALUES
('fr-FR', 'root', 'ui.lang_mismatch_title', 'Langue différente'),
('fr-FR', 'root', 'ui.lang_mismatch_body', 'Cette page est en'),
('fr-FR', 'root', 'actions.lang_mismatch_switch', 'Garder ma langue'),
('fr-FR', 'root', 'actions.lang_mismatch_dismiss', 'Rester ici'),
('fr-FR', 'root', 'ui.locale_names.en-US', 'anglais'),
('fr-FR', 'root', 'ui.locale_names.sl-SI', 'slovène'),
('fr-FR', 'root', 'ui.locale_names.fr-FR', 'français');
locale_names is for nominative use ("English"); language_names_to is for the "Switch to X" prepositional context where French (and many other languages) inflects the noun differently. For English both forms happen to be the same, but for other languages they diverge.
The ui.language_names_to entries (like ui.language_name* / ui.language_names*) are exempt from sync-translations' identical-to-English check - see Excluded Translations. The exclusion lives in config/excluded_translations.ts, so a language's native name ("English" in every locale) isn't re-sent to the AI on every sync run. The ui.locale_names.* picker labels above are not exempt - if you seed them with the same value in every locale, sync-translations will flag them as untranslated until you translate them.
To test: visit any page with ?locale=fr-FR, then change the URL to a Slovenian-only path (/o-nas if your project has it). The dialog should appear, in French, asking whether to stay or switch.
Step 6: Locale-Aware Formatting
Date and currency helpers automatically use the active locale via props.locale:
<p>{= js_date_to_locale_string(record.created_at) }</p>
<!-- en-US: "1/15/2026" sl-SI: "15. 1. 2026" fr-FR: "15/01/2026" -->
<p>{~ display_currency(record.price) }</p>
<!-- en-US: "€1,234.56" sl-SI: "1.234,56 €" fr-FR: "1 234,56 €" -->
Because we registered fr-FR as a full BCP 47 locale in Step 1, every helper that takes an optional locale uses French formatting on French pages without per-call configuration.
For locale-specific currency - euro for European locales, dollar for US English, yen for Japanese - pass the symbol explicitly when it varies per locale:
{~ display_currency(record.price, props.locale, false, props.locale === 'fr-FR' ? '€' : '$') }
A cleaner pattern is a helper map per locale, or a translation key that holds the symbol:
-- root translation row: key_path = 'currency_symbol', translation = '€'
{~ display_currency(record.price, props.locale, false, props.currency_symbol) }
Step 7: The html lang Attribute
The <html lang="..."> attribute should reflect the current page's locale for screen readers and search engines. The shipped layout already does this:
<html lang="{= props.locale }"></html>
props.locale is auto-injected by render() based on the resolution chain (see Locales). After Step 1, French pages get <html lang="fr-FR"> automatically - no further changes needed.
For stricter SEO - separate URLs per locale, sitemap entries per locale, hreflang annotations - generate them from the route map:
<!-- in <head> -->
{#each props.active_locales as code } {{ const localized = localized_path_for(props.request_url, code) }} {#if
localized && code !== props.locale }
<link rel="alternate" hreflang="{= code }" href="{= localized }" />
{/if} {/each}
hreflang tags tell search engines which URL serves each locale version. Combined with localised URLs, this gives search engines the full picture of a multilingual site.
Step 8: Finding Untranslated Strings
Because add-locale seeds the new locale with default-locale values, an untranslated key shows up as a row whose French value still equals the source value. Query the translations table to list them:
SELECT fr.namespace, fr.key_path, fr.translation
FROM translations fr
JOIN translations src
ON src.namespace = fr.namespace AND src.key_path = fr.key_path AND src.locale = 'en-US'
WHERE fr.locale = 'fr-FR' AND fr.translation = src.translation;
Each row is a key that hasn't been translated yet (or that happens to be identical across locales - proper nouns, "OK"). The /system/translations admin UI is the convenient place to work through them; bun reeman sync-translations --translate will AI-fill the same gaps in one pass.
Keys under the excluded prefixes in config/excluded_translations.ts (notably ui.language_name* and ui.language_names_to) are intentionally identical across locales, so sync-translations skips the identical-value check for them - see Excluded Translations.
Browse the site in the new locale with the dev server's props.toJSON debug. In dev mode, every render injects props.toJSON (the full data payload as JSON). Append a <pre>{~ props.toPrettyJSON }</pre> to a development-only debug route, navigate the site in French, and any value that's still in English shows up in the JSON dump.
Removing a Locale
Removing a locale is a single reeman action - the reverse of adding one:
bun reeman
# pick "Remove locale" → choose the code → confirm
It strips the code from config/supported_locales.ts, deletes every row for that locale from the translations table, and cleans up cross-locale references (the ui.locale_names / ui.language_names_to entries other locales held for it). If you remove the current default_locale, it picks the first remaining locale as the new default. The CLI form is bun reeman remove-locale <locale_code> [--force] [--new-default <locale_code>] (--force skips the confirmation prompt for scripted use; --new-default picks the fallback default locale instead of the first one remaining).
What's Next
You have a third locale wired in end-to-end - strings, URLs, the mismatch dialog, locale-aware formatting. Adding a fourth (or removing one) is exactly the same flow.
To go deeper:
- Translations - the namespace model and missing-key behaviour.
- Localized Routes - the route-map machinery that powers URL aliasing.
- Locales - locale resolution, the X-Locale header, building a custom locale picker.
