Locales

Reepolee ships with a complete internationalisation system: translated strings per-route and globally, locale-aware date and currency formatting, locale-localised URLs, and a mismatch dialog when the page's locale 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 locales are supported, how the default is chosen, and how the active locale is resolved on each request. The other two i18n pages cover database-backed strings (Translations) and locale-localised URLs (Localized Routes).

The Config File

Locales are declared in config/supported_locales.ts:

// all locales with translations/content
export const locales = ["en-US", "sl-SI"] as const;

// locale chooser from this list
export const active_locales = ["sl-SI", "en-US"] as const;

// first served without selection; its content lives in the source columns
export const default_locale = "en-US";

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

// UI-string serving aliases: requests for the key locale render the value
// locale's translations (e.g. { "de-AT": "de-DE" }). One level only; targets
// must not themselves be aliased. Content values are never aliased - they are
// copied per locale in the CRUD editor.
export const locale_aliases: Record<string, string> = {};

A locale is a full BCP 47 identifier (en-US, sl-SI, de-AT) - the single localization axis: one locale is one complete visitor experience (UI strings, content, prices). Each export has a specific purpose:

  • locales - every locale loaded from the translations table.
  • active_locales - what the locale picker offers users. Usually equal to locales, but can be narrower while you're translating a new locale behind the scenes.
  • default_locale - what gets served when no ?locale=... query param, no locale cookie, and no locale-localised URL match are available. Its content lives in the source columns.
  • locale_names - display labels for the locale picker.
  • locale_aliases - UI-string serving aliases. A request for an aliased locale (e.g. de-AT) renders the target locale's (de-DE) translations - handy for serving one set of UI strings to several regions. Aliases are one level only, targets must not themselves be aliased, and content values are never aliased (they are copied per locale in the CRUD editor).

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

Locale Resolution

On every request, the set_locale(active_locales) middleware resolves the active locale and sets the X-Locale header on the request. The render layer reads X-Locale and uses it to choose the right translations.

The resolution order is:

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

The middleware also writes a second header, X-Locale-Preferred, that carries the user's cookie-stored preference (regardless of the resolved page locale). This is what powers the locale-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 Locale in Handlers

Most handlers don't need to read the locale directly - create_ctx(req, import.meta.dir) exposes it as ctx.locale (and the alias-resolved UI locale as ctx.ui_locale) and provides the merged translations on ctx.translations. When code without a context needs the raw locale, use the shared resolver:

import { resolve_locale } from "$lib/route";

const locale = resolve_locale(req);

resolve_locale() reads the locale selected by set_locale from X-Locale, then uses the cookie or configured default when middleware has not run. Do not duplicate this parsing in handlers.

In templates, props.locale is the active BCP 47 locale. It is injected automatically by render():

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

The props.active_locales and props.locale_names exports are also pre-populated in every render, so the locale switcher doesn't need a per-handler data entry - it's already there.

Building a Locale Switcher

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

<nav class="flex gap-2 text-base">
    {#each props.active_locales as code }
    <a
        href="{~ localized_path(props.request_url) }?locale={= code }"
        class="{= props.locale === code ? 'font-bold' : 'text-muted' }"
    >
        {= props.locale_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.
  • ?locale={= code } is what tells set_locale to switch. The middleware sees the query param, sets the cookie, and redirects to the right localised URL.

The switcher has no JavaScript. In the default layout it is always visible at the bottom left, with the active locale in bold. The query param + redirect approach means every link is shareable - copying a ?locale=en-US URL into chat sends the recipient the English version regardless of their cookie.

Locale-Aware Formatting

The built-in template helpers use props.locale (the active BCP 47 locale) for date and number formatting automatically:

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

<p>{~ display_currency(record.price) }</p>
<!-- en-US: "€1,234.56" - sl-SI: "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 locale without having to pick the locale string by hand.

When No Locale Fits

If a user requests ?locale=fr-FR and fr-FR isn't in active_locales, 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-FR and you remove fr-FR from active_locales, the next request resolves to the default.

If a translation key is missing in a non-default locale, the loader inserts a visible braced placeholder such as {title}. The missing text stays obvious during development instead of silently borrowing a value from another locale.

What Happens at Server Startup

config/supported_locales.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.all, routes, active_locales);
const aliased_routes = expand_route_aliases_from_maps(routes, active_locales);
const routed = wrap_all_routes(aliased_routes, set_locale(active_locales));

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 locale. See Localized Routes.