Adding a New Language

Introduction

A Reepolee project ships with whatever languages you need - there's no "framework default" beyond the languages you declare. Adding one means registering the language code, seeding its translations, and optionally localising URLs; the system picks it up automatically. Translations cascade across languages so a partial translation is never broken; missing keys fall back to a language that has them.

Translations are DB-first. The translations database table is the source of truth. The Add language tool copies the English rows for the new language 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 language code your project needs.

The Fast Path - bun reeman

reeman - add language flow (enter code → AI-translate)

The interactive setup tool wraps the whole add-a-language workflow:

bun reeman
# pick "Add language" → enter "fr" → answer "y" to AI-translate

What it does in one shot:

  • adds the code to languages, active_languages, language_names, and language_locales in config/supported_languages.ts
  • reads every English row from the translations table and inserts a copy for the new language across all namespaces, so the new language starts fully populated (with English values as placeholders)
  • updates the ui.language_names / ui.language_names_to entries for every existing language so the new language shows up in their pickers
  • when you opt in to AI translation, runs the configured AI provider and translates the new rows in the database. The provider is resolved by environment: Ollama (OLLAMA_URL, local, highest priority), then Gemini (GEMINI_API_KEY, the shipped default), then Hugging Face (HF_TOKEN, only when OPENROUTER_KEY is unset), then OpenRouter - see Dynamic Translations → Choosing a Provider

The rest of this page walks through the underlying pieces, plus the URL-localisation and language-mismatch dialog details. With reeman fast path you can skip straight to Step 4: Localise URLs.

Step 1: Register the Language

Open config/supported_languages.ts and add "fr" to both languages and active_languages:

export const languages = ["en", "sl", "fr"] as const;
export const active_languages = ["sl", "en", "fr"] as const;
export const default_language = "sl";

export const language_names: Record<string, string> = {
    en: "English",
    sl: "Slovenian",
    fr: "French",
};

export const language_locales: Record<string, string> = {
    en: "en-US",
    sl: "sl-SI",
    fr: "fr-FR",
};

The two arrays serve different purposes:

  • languages - every code loaded from the translations table.
  • active_languages - the codes the language picker shows users. Usually equal to languages, but you can keep a language out of the picker while you're translating it (set active_languages to ["sl", "en"] and languages to ["sl", "en", "fr"]; French strings load but no UI offers French to users).

language_names is what the picker displays. language_locales is the BCP-47 locale used by date and currency formatters - see Languages & 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-language generator next.

Step 2: Create Root Translations

The add-language generator copies the English translation rows for French. If you registered the language by editing the config, run it explicitly:

bun generator/add_language.ts 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 lang = 'fr' AND namespace = 'root' AND key_path = 'labels.save';

Keep the same key paths as English. 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 /?lang=fr and the labels appear in French - the language switcher in the layout now offers French alongside Slovenian and English.

Step 3: Translate Route-Specific Strings

Every namespace that has English and Slovenian rows needs French rows for full coverage. The Add language reeman does this for you when you add the code (above): it copies every English row in the translations table to the new language so French starts fully populated with English values as placeholders. If you registered the language by editing the config by hand, run the same step explicitly:

bun generator/add_language.ts fr            # copy EN rows → FR in the DB
bun generator/add_language.ts 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?lang=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:languages scans the translations table for keys present in one language but missing (or still untranslated) in another, translates them, and writes the results back to the database:

bun run sync:languages

This uses whichever AI provider your environment selects - Ollama, Gemini, OpenRouter, or Hugging Face (Choosing a Provider). With the shipped Gemini default, set GEMINI_API_KEY in .env; the run sends each key with the English 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 (lang, namespace, key_path, translation)
VALUES ('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 /connexion directly - the login form renders in French (because the URL implies French).
  • Switching to French via ?lang=fr from 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 languages. If you don't translate the URL for a specific feature, the canonical English 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 Language-Mismatch Dialog

A French-speaking user (cookie says lang=fr) might land on /login (the English URL) by clicking a link in an email or sharing. To avoid silently switching languages, the layout includes a dialog that detects the mismatch and offers to switch.

The dialog uses the user's preferred language (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 (lang, namespace, key_path, translation) VALUES
    ('fr', 'root', 'lang_mismatch_title', 'Langue différente'),
    ('fr', 'root', 'lang_mismatch_body', 'Cette page est en'),
    ('fr', 'root', 'lang_mismatch_switch', 'Garder ma langue'),
    ('fr', 'root', 'lang_mismatch_dismiss', 'Rester ici'),
    ('fr', 'root', 'lang_mismatch_switch_to', 'Passer à'),
    ('fr', 'root', 'language_names_to.en', 'anglais'),
    ('fr', 'root', 'language_names_to.sl', 'slovène'),
    ('fr', 'root', 'language_names_to.fr', 'français');

language_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.

To test: visit any page with ?lang=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 language's locale via props.locale:

<p>{= js_date_to_locale_string(record.created_at) }</p>
<!-- en: "1/15/2026"  sl: "15. 1. 2026"  fr: "15/01/2026" -->

<p>{~ display_currency(record.price) }</p>
<!-- en: "€1,234.56"  sl: "1.234,56 €"  fr: "1 234,56 €" -->

Because we set language_locales.fr = "fr-FR" 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 languages, dollar for US English, yen for Japanese - pass the symbol explicitly when it varies per language:

{~ display_currency(record.price, props.locale, false, props.lang === 'fr' ? '€' : '$') }

A cleaner pattern is a helper map per language, 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 language for screen readers and search engines. The shipped layout already does this:

<html lang="{= props.lang }"></html>

props.lang is auto-injected by render() based on the resolution chain (see Languages & Locales). After Step 1, French pages get <html lang="fr"> automatically - no further changes needed.

For stricter SEO - separate URLs per language, sitemap entries per locale, hreflang annotations - generate them from the route map:

<!-- in <head> -->
{#each props.active_languages as code } {{ const localized = localized_path_for(props.request_url, code) }} {#if
localized && code !== props.lang }
<link rel="alternate" hreflang="{= code }" href="{= localized }" />
{/if} {/each}

hreflang tags tell search engines which URL serves each language version. Combined with localised URLs, this gives search engines the full picture of a multilingual site.

Step 8: Finding Untranslated Strings

Because add_language seeds the new language with English values, an untranslated key shows up as a row whose French value still equals the English one. Query the translations table to list them:

SELECT fr.namespace, fr.key_path, fr.translation
FROM translations fr
JOIN translations en
  ON en.namespace = fr.namespace AND en.key_path = fr.key_path AND en.lang = 'en'
WHERE fr.lang = 'fr' AND fr.translation = en.translation;

Each row is a key that hasn't been translated yet (or that happens to be identical across languages - proper nouns, "OK"). The /system/translations admin UI is the convenient place to work through them; bun run sync:languages will AI-fill the same gaps in one pass.

Browse the site in the new language 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 Language

Removing a language is a single reeman action - the reverse of adding one:

bun reeman
# pick "Remove language" → choose the code → confirm

It strips the code from config/supported_languages.ts, deletes every row for that language from the translations table, and cleans up cross-language references (the ui.language_names / ui.language_names_to entries other languages held for it). If you remove the current default_language, it picks the first remaining language as the new default. The CLI form is bun generator/remove_language.ts <code> [--force] (--force skips the confirmation prompt for scripted use).

What's Next

You have a third language 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: