Localized Routes

Reepolee translates URLs through route_name rows in the translations table. A Slovenian route_name = "prijava" makes /login reachable as /prijava, and /system/users reachable as /sistem/uporabniki. The same handler runs at both URLs; the path is just an alias for the canonical English route.

Localised URLs are useful for SEO (each locale gets its own indexable URLs), for user comprehension (a non-English speaker reading a URL bar can recognise the page), and for sharing (a Slovenian URL stays Slovenian when you send it to a friend). All of it works without extra code in your handlers - the server builds the localised aliases automatically at boot from the translations table.

How Localisation Is Declared

The route_name key in a translation row tells the route map how to localise that segment. The value is automatically slugified via slugify() in lib/route_map.ts - it transliterates Unicode characters to ASCII, lowercases, and replaces whitespace with hyphens. Add the Slovenian segment names through /system/translations or SQL:

INSERT INTO translations (locale, namespace, key_path, translation) VALUES
    ('sl-si', 'system', 'route_name', 'sistem'),
    ('sl-si', 'system.users', 'route_name', 'uporabniki');

When the route table is assembled at boot via build_route_maps() from lib/route_map.ts, the canonical path /system/users walks each segment and looks up its translation via the route map. system becomes sistem, users becomes uporabniki, and the full localised path is /sistem/uporabniki.

If a segment has no route_name row, the canonical segment is used as-is. So a partially-localised app is fine - you can translate system without translating users yet, and the URL becomes /sistem/users until you fill in the rest.

Note: route_name is the only key that never inherits across locales. A Slovenian route_name is not used as fallback for English, and vice versa - this prevents accidental URL hijacking between locales.

Canonical vs Localised Paths

Throughout the codebase, two terms come up:

  • Canonical path - the path as it appears in routes/routes.ts. Always English by convention. /users, /login, /system/users, /users/:id/edit.
  • Localised path - the version for a specific locale. /uporabniki, /prijava, /sistem/uporabniki, /uporabniki/:id/uredi.

Internally, route handlers, helpers, and database calls work with canonical paths. URLs in HTML - links, form actions, redirect targets - should be localised so users navigate within their locale. The localized_path() template helper is the bridge.

The localized_path Helper

localized_path(canonical) returns the localised version of a canonical path for the active locale:

<a href="{~ localized_path('/login') }">Log in</a>
<a href="{~ localized_path('/profile') }">Profile</a>
<form method="POST" action="{~ localized_path(props.action) }">...</form>

Note the raw output tag {~ } - the localised path is HTML-safe by construction and shouldn't be double-escaped. (In particular, paths containing accented characters would get mangled if escaped.)

localized_path() handles three cases:

  • Exact canonical matches - /login/prijava (in Slovenian).
  • Routes with dynamic segments - /users/:id/edit with :id placeholder → /uporabniki/:id/uredi (placeholders preserved).
  • Concrete URLs with values - /users/42/edit/uporabniki/42/uredi (values copied through to the matching positions).

If there's no localised version available (no route_name in any segment), it returns the canonical path unchanged.

Automatic Route Registration

In server.ts, two functions wire up the localisation at startup:

import { build_route_maps, expand_route_aliases_from_maps } from "$lib/route_map";

// Build the canonical ↔ localised lookup tables
build_route_maps(translations.all, routes, active_locales);

// Register each route at every localised variant
const aliased_routes = expand_route_aliases_from_maps(routes, active_locales);

const routed = wrap_all_routes(aliased_routes, set_locale(active_locales));

expand_route_aliases_from_maps() takes your canonical route table and produces an expanded table that includes every localised variant pointing to the same handler. The original canonical routes stay in place too - so /login and /prijava both work, both serve the same handler, both have the locale resolved correctly.

The result: you write your route table once, in English, and every supported locale has its routes registered automatically. Adding a new locale requires no changes to routes/routes.ts.

Locale Detection From Path

When a user lands on /prijava, the locale middleware infers the locale from the URL alone - no cookie or query parameter needed:

import { detect_locale } from "$lib/route_map";

const path_locale = detect_locale(url.pathname);
// Returns "sl-si" for /prijava, "en-us" for /login, null for /api/...

If the path has a localised match in exactly one locale, that's the locale. If it matches in all locales (a route that isn't localised at all), the function returns null and the cookie or default determines the locale.

The set_locale middleware uses path detection as priority #2 after the explicit ?locale= query param. The resolution chain is documented in Locales.

The Locale-Mismatch Dialog

German locale-mismatch dialog over an English Locales page with actions to keep the link locale or use the current locale

A user whose cookie says they prefer English can still land on /prijava - by clicking a Slovenian link in an email, by editing the URL, by sharing. To avoid silently switching locales, Reepolee's render layer detects the mismatch and exposes it to the layout:

  • props.path_locale is the locale detected from the URL.
  • props.locale_preferred is the user's cookie-stored preference (set via X-Locale-Preferred).
  • When they differ, props.path_locale_name is injected - the display name of the URL's locale, rendered from the user's preferred locale translations.

The shipped layout (routes/layout.ree) renders the dialog and opens it with a one-line showModal() call. The dialog strings come from the ui.lang_mismatch_* / actions.lang_mismatch_* translation keys (translated into the user's preferred locale by the render layer):

{#if props.path_locale && props.locale_preferred && props.path_locale !== props.locale_preferred }
<dialog id="lang_mismatch_dialog">
    <h2>{_ ui.lang_mismatch_title }</h2>
    <p>{_ ui.lang_mismatch_body } <strong>{= props.path_locale_name }</strong></p>
    <form method="dialog">
        <button>{_ actions.lang_mismatch_dismiss }</button>
    </form>
    <a href="?locale={= props.locale_preferred }">{_ actions.lang_mismatch_switch }</a>
</dialog>
<script>
    document.getElementById("lang_mismatch_dialog")?.showModal();
</script>
{/if}

The dialog uses two native mechanisms, no JavaScript needed beyond the showModal() call:

  • <form method="dialog"> - the dismiss button closes the dialog when submitted. The user stays on the URL's locale; their cookie is unchanged.
  • <a href="?locale=<preferred>"> - the switch link triggers set_locale middleware to redirect to the same page in the user's preferred locale and update the locale cookie.

The dialog renders in the user's preferred locale (so an English-speaker landing on a Slovenian page sees the dialog in English), making the offer comprehensible regardless of which side of the mismatch the user is on. See Dialogs for the general pattern.

Redirecting on Locale Switch

When ?locale=xx-yy is in the URL (matched case-insensitively, so xx-YY also works), the set_locale middleware does two things before letting the handler run:

  1. Resolves the canonical path of the current URL (in case the user is on a localised version).
  2. Builds the localised path in the target locale.
  3. Returns a 302 redirect to the new URL, with the locale cookie set, and the ?locale= query param stripped.

So /login?locale=sl-si becomes a redirect to /prijava with a Set-Cookie: locale=sl-si header. The URL the user sees is clean (no query param), shareable (links stay shareable across locales), and the cookie is updated so future visits use the same locale.

The Route-Map API

For programmatic localisation outside templates - building canonical-aware redirects, checking which locale a URL belongs to, generating sitemap entries - the route-map module exposes its lookups:

import {
    resolve_canonical, // /prijava + "sl-si" → /login
    resolve_localized, // /login + "sl-si"        → /prijava
    detect_locale, // /prijava        → "sl-si" (or null)
    build_route_maps,
} from "$lib/route_map";

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

localized_url(path, locale) is the handler-side equivalent of the localized_path template helper - it returns the localised path for a given locale, preserving query strings. Use it when redirecting:

const locale = resolve_locale(req);
return Response.redirect(localized_url("/login", locale), 303);

Without localized_url, a Slovenian user who is logged out gets redirected to /login and immediately re-redirects through set_locale to /prijava. With it, the response goes straight to the localised URL.

Routes You Don't Want to Localise

API endpoints, internal admin tools, anything machine-facing - these should usually stay canonical regardless of locale. To skip localisation for a route, do not add route_name rows for its segments.

/api/users/:id will work in every locale, because no segment has a route_name translation. No redirect, no cookie change - just the route as written in routes.ts. Users (and external clients) see exactly one URL for each API endpoint.

For mixed cases - a page that should be localised but inside a section that isn't - give that page its own route_name translation. The route map walks segment-by-segment, so localising a leaf doesn't require localising the path above it.