Languages & Locales

ReeWeb has first-class support for multi-locale sites. Every page renders in every configured locale, the default locale is served at the root of your site, and other locales get a locale-prefixed URL path. Locale is the single localization axis - a lowercase BCP 47 language-region code such as en-us or sl-si - used for routing, template-variant selection, translation lookup, and date/currency formatting alike. The configuration lives in a single file.

Configuration

Locales are declared in config/supported_locales.ts:

// All locales with translation files
export const locales = ["en-us", "sl-si"] as const;

// Locales shown in the locale picker
export const active_locales = ["sl-si", "en-us"] as const;

// Locales built but excluded from sitemap, feeds, hreflang, and the picker
export const soft_launch_locales: string[] = [];

// First served without selection (no /{locale} prefix)
export const default_locale = "sl-si";

// Human-readable names for the locale picker
export const locale_names: Record<string, string> = {
    "en-us": "English",
    "sl-si": "Slovenian",
};

// UI-string serving aliases: requests for the key locale render the value
// locale's translations (e.g. { "de-at": "de-de" }). One level only.
export const locale_aliases: Record<string, string> = {};

Each export has a specific purpose:

  • locales - every locale that has translation files in the project. The translation loader (lib/i18n.ts) walks src/public/ looking for <locale>.json files matching this list (e.g. en-us.json, sl-si.json).
  • active_locales - what the locale picker offers site visitors and what the build renders pages for. Usually equal to locales, but can be narrower while a locale is still being translated.
  • soft_launch_locales - locales that are still built (so the URLs exist and are reachable), but are dropped from the sitemap, RSS/JSON feeds, and the hreflang cluster, and are excluded from the picker, and marked robots: noindex. This is the per-locale counterpart of a draft page (see Content Visibility) - use it to publish a translation for review before announcing it.
  • default_locale - what gets served at the site root (no locale prefix) and is the fallback template/markdown variant when a locale-specific one is missing.
  • locale_names - display labels for the locale picker.
  • locale_aliases - lets a configured locale (routes, builds, gets its own URLs) share another locale's UI-string translations instead of owning its own translation files. One level only; alias targets must not themselves be aliased.

src/lib/locale.ts validates this config at first import and fails loudly - a malformed locale, an unknown alias, an alias chain, or an aliased default all throw immediately at startup rather than surfacing as a confusing runtime gap.

Locale Shape

A locale is always the canonical lowercase BCP 47 form - en-us, sl-si, de-at. This is the single identity used everywhere: config, object keys, translation filenames, route-map keys, and URL path segments. BCP 47 tags are case-insensitive, so lowercase is a fully valid spelling; Intl accepts it directly.

Case only comes back for conventional presentation output - the hreflang attribute and Open Graph's og:locale meta tag - via format_bcp47(), which formats to the mixed-case form search engines and social platforms expect (en-us -> en-US). Incoming values (a request path, a URL typed by hand) are matched against the configured list case-insensitively via canonical_locale(), so /EN-US/ and /en-us/ both resolve.

import { locale_language, locale_region, locale_url_segment, canonical_locale, format_bcp47 } from "$root/src/lib/locale";

locale_language("sl-si");    // "sl" - short language subtag, for <html lang="...">
locale_region("sl-si");      // "si"
locale_url_segment("sl-si"); // "sl-si" - identity: locale is already the URL/filename form
canonical_locale("SL-si");   // "sl-si" - or null if not configured
format_bcp47("sl-si");       // "sl-SI" - conventional casing, for hreflang/og:locale

How Locales Affect URLs

The default locale is served at the root of your site - no URL prefix. All other locales are served under their locale segment:

/                   Slovenian (default)
/about/             Slovenian
/english-only/      Slovenian (English-only page renders at root)
/en-us/             English index page
/en-us/about/       English about page

This is handled automatically by both the SSG script (scripts/ssg.ts) and the dev server (scripts/dev.ts). The URL structure is:

/{locale}/{localized-path}/

Where {localized-path} may differ per locale if you use route_name in translations (see Localized Routes).

Adding a New Locale

To add a locale, say German (de-de):

  1. Add "de-de" to locales, active_locales, and locale_names in config/supported_locales.ts
  2. Create a de-de.json translation file in each directory that has an en-us.json or sl-si.json

That's it. The scripts automatically discover the new translation files and render every page in German. No other configuration is needed. The MCP add_locale tool automates this - see Translations and the MCP Server reference.

See the Adding a Locale recipe for a step-by-step walkthrough.

Locale-Variant Templates

Most pages share the same template across locales - only the translation strings differ. For pages that need different markup per locale (a landing page with locale-specific hero content, for example), you can create locale-suffixed template variants:

  • about/index.en-us.ree - English version
  • about/index.sl-si.ree - Slovenian version

The resolution chain is:

  1. {name}.{requested_locale}.ree - exact match for the current locale
  2. {name}.{default_locale}.ree - fallback to the default locale
  3. {name}.ree - generic fallback

This applies to every template load - pages, layouts, includes, and components, and the same chain applies to markdown files via resolve_md_file().

Reading the Active Locale in Templates

props.locale is the active lowercase BCP 47 locale for the page being rendered, injected automatically into the render data (scripts/shared/page_data.ts). Templates read it directly:

<html lang="{= props.html_lang }">
    ...
    <p>{= js_date_to_locale_string(record.created_at) }</p>
</html>

Two related fields exist for a reason: props.locale is the full lowercase BCP 47 code (sl-si) and is what everything - routing, translation lookup, Intl formatting - keys off. props.html_lang is the short language subtag (sl) computed once via locale_language(props.locale), and exists only because <html lang="..."> conventionally takes the short form. Nowhere else in a template should you need the short form.

The props.active_locales, props.locale_names, and props.locale_self_names fields are also pre-populated in every render, so a locale picker doesn't need a per-page props entry - it's already there.

Creating a Locale Picker

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

<div class="lang-switcher">
    {#each props.active_locales as l}
        <a
            href="{~ localized_path_for_locale(l, props.canonical_path) }"
            class="{= props.locale === l ? 'active' : '' }"
        >{= props.locale_self_names[l]}</a>
    {/each}
</div>
  • localized_path_for_locale(l, props.canonical_path) resolves the current canonical page to its localized URL in locale l - if you're on /o-nas (the Slovenian localization of /about) and switch to English, the link goes to /en-us/about, not the homepage.
  • props.locale_self_names holds each locale's own name for itself, read from that locale's ui.language_names translation key (so the Slovenian entry reads "Slovenščina", not "Slovenian").

The picker is a set of plain links to the already-rendered per-locale pages - no query param, no redirect, no client JS. Every link is directly shareable.

Locale-Aware Formatting

The built-in template helpers use props.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, pass props.locale (or any BCP 47 string) directly to Intl:

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

What Happens at Build/Boot

config/supported_locales.ts is read and validated once on import. lib/i18n.ts walks src/public/ and constructs the translation tree, keyed by locale, at the start of both the SSG build (scripts/ssg/pipeline.ts) and the dev server (scripts/dev/site_state.ts). lib/static_site.ts's build_static_route_map() then builds the canonical → per-locale localized path lookup used by localized_path() and the URL builders. Every active locale gets its own fully rendered page tree - there is no runtime locale negotiation, no cookie, and no request header involved; the locale a visitor sees is simply the one whose URL they requested.