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) walkssrc/public/looking for<locale>.jsonfiles 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 tolocales, 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 markedrobots: 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):
- Add
"de-de"tolocales,active_locales, andlocale_namesinconfig/supported_locales.ts - Create a
de-de.jsontranslation file in each directory that has anen-us.jsonorsl-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 versionabout/index.sl-si.ree- Slovenian version
The resolution chain is:
{name}.{requested_locale}.ree- exact match for the current locale{name}.{default_locale}.ree- fallback to the default locale{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 localel- 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_namesholds each locale's own name for itself, read from that locale'sui.language_namestranslation 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.