Languages & Locales

Introduction

Reepolee ships with a complete internationalisation system: translated strings per-route and globally, locale-aware date and currency formatting, language-localised URLs, and a mismatch dialog when the page's language doesn't match the user's preferred one. All of it runs server-side - there is no client-side translation step and no JSON to ship to the browser.

This page covers the configuration: which languages are supported, how the default is chosen, and how the active language is resolved on each request. The other two i18n pages cover database-backed strings (Translations) and language-localised URLs (Localized Routes).

The Config File

Languages are declared in config/supported_languages.ts:

// Languages loaded from the translations table
export const languages = ["en", "sl"] as const;

// Languages the user can pick (subset of `languages`)
export const active_languages = ["sl", "en"] as const;

// First served when no preference is known
export const default_language = "sl";

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

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

Each export has a specific purpose:

  • languages - every language code loaded from the translations table.
  • active_languages - what the language picker offers users. Usually equal to languages, but can be narrower while you're translating a new language behind the scenes.
  • default_language - what gets served when no ?lang=... query param, no lang cookie, and no language-localised URL match are available.
  • language_names - display labels for the language picker.
  • language_locales - the BCP-47 locale used for date, number, and currency formatting. Specify the region (for example en-US, en-GB, or sl-SI) if formatting must be consistent, since regional conventions such as date order, decimal separators, and currency formatting can differ.

Adding a new language is four changes - extend languages, optionally extend active_languages, add the display name and locale, then add its rows to the translations table. The fastest path is bun reemanAdd language, which makes the config edit, copies the English rows into the database, and can run the AI translation pass in one step. See Adding a New Language for the full walkthrough.

Language Resolution

On every request, the set_lang(active_languages) middleware resolves the active language and sets the X-Lang header on the request. The render layer reads X-Lang and uses it to choose the right translations.

The resolution order is:

  1. ?lang=xx query parameter - explicit user choice. Always wins. Also sets the lang cookie and redirects to the localised URL.
  2. Language detected from the URL path - e.g., /avtentikacija/prijava is detected as Slovenian. See Localized Routes.
  3. lang cookie - the user's previous choice.
  4. default_language - final fallback.

The middleware also writes a second header, X-Lang-Preferred, that carries the user's cookie-stored preference (regardless of the resolved page language). This is what powers the language-mismatch dialog: if the page is in Slovenian but the user's cookie says they prefer English, the layout can offer to switch.

Reading the Active Language in Handlers

Most handlers don't need to read the language directly - create_ctx(req, import.meta.dir) resolves it and exposes the merged translations on ctx.translations. When you do need the raw code:

function get_lang(req: BunRequest): string {
    return req?.headers?.get("X-Lang") || "en";
}

The pattern is one line because set_lang middleware has already done the work. Reading the cookie or query param directly is unnecessary - and would skip the localised-URL path detection.

In templates, props.lang is the active language code and props.locale is the corresponding BCP-47 locale. Both are injected automatically by render():

<html lang="{= props.lang }">
    ...
    <p>{= js_date_to_locale_string(record.created_at) }</p>
    <!-- locale_date uses props.locale by default -->
</html>

The props.active_languages and props.language_names exports are also pre-populated in every render, so the language picker doesn't need a per-handler data entry - it's already there.

Building a Language Picker

Screenshot - language picker

A complete picker that uses the canonical-to-localised URL helper:

<nav class="flex gap-2 text-base">
    {#each props.active_languages as code }
    <a
        href="{~ localized_path(props.request_url) }?lang={= code }"
        class="{= props.lang === code ? 'font-bold' : 'text-muted' }"
    >
        {= props.language_names[code] }
    </a>
    {/each}
</nav>

Two things to notice:

  • localized_path(props.request_url) ensures the URL stays on the current page - if you're on /prijava (the Slovenian login URL) and click "English," the link goes to /login, not the homepage.
  • ?lang={= code } is what tells set_lang to switch. The middleware sees the query param, sets the cookie, and redirects to the right localised URL.

The picker has no JavaScript. The query param + redirect approach means every link is shareable - copying a ?lang=en URL into chat sends the recipient the English version regardless of their cookie.

Locale-Aware Formatting

The built-in template helpers use props.locale (derived from props.lang) for date and number formatting automatically:

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

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

For one-off formatting in custom helpers or in the route handler, use props.locale directly:

new Intl.NumberFormat(props.locale, { style: "decimal" }).format(value);

This way the formatting always matches the user's language without having to pick the locale string by hand.

When Translation Is Missing

If a user requests ?lang=fr and fr isn't in active_languages, the middleware ignores the query param and falls through to the cookie or default. The same holds for the cookie - if a user's cookie says fr and you remove fr from active_languages, the next request resolves to the default.

If a translation key is missing in the active language but present in another, the missing-fill step (covered in Translations) copies the value from a language that has it. The user sees the key in some language rather than seeing nothing - usually the right trade-off while translations are being filled in.

What Happens at Server Startup

config/supported_languages.ts is read once on import. At startup, lib/i18n.ts loads the translation tree from the database. lib/route_map.ts then builds the localised-URL lookup tables:

build_route_maps(translations, routes, active_languages);
const aliased_routes = expand_route_aliases_from_maps(routes, translations, active_languages);
const routed = wrap_all_routes(aliased_routes, set_lang(active_languages));

So every routes/routes.ts entry - /users, /login, /profile - is automatically registered at every localised variant (/uporabniki, /prijava, /profil) without you writing additional route entries. The handler is the same; the path is different per language. See Localized Routes.