Localized Routes
Introduction
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 language 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_aliases.ts - it transliterates Unicode characters to ASCII (e.g., ü → ue, š → s), lowercases, and replaces whitespace with hyphens. Add the Slovenian segment names through /system/translations or SQL:
INSERT INTO translations (lang, namespace, key_path, translation) VALUES
('sl', 'system', 'route_name', 'sistem'),
('sl', '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 languages. A Slovenian route_name is not used as fallback for English, and vice versa - this prevents accidental URL hijacking between languages.
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 language.
/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 language. 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 language:
<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/editwith:idplaceholder →/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, routes, active_languages);
// Register each route at every localised variant
const aliased_routes = expand_route_aliases_from_maps(routes, translations, active_languages);
const routed = wrap_all_routes(aliased_routes, set_lang(active_languages));
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 language resolved correctly.
The result: you write your route table once, in English, and every supported language has its routes registered automatically. Adding a new language requires no changes to routes/routes.ts.
Language Detection From Path
When a user lands on /prijava, the language middleware infers the language from the URL alone - no cookie or query parameter needed:
import { detect_lang_from_path } from "$lib/route_aliases";
const path_lang = detect_lang_from_path(url.pathname, translations);
// Returns "sl" for /prijava, "en" for /login, null for /api/...
If the path has a localised match in exactly one language, that's the language. If it matches in all languages (a route that isn't localised at all), the function returns null and the cookie or default determines the language.
The set_lang middleware uses path detection as priority #2 after the explicit ?lang= query param. The resolution chain is documented in Languages & Locales.
The Language-Mismatch Dialog
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 languages, Reepolee's render layer detects the mismatch and exposes it to the layout:
props.path_langis the language detected from the URL.props.lang_preferredis the user's cookie-stored preference (set viaX-Lang-Preferred).- When they differ, additional fields are injected (
lang_mismatch_title,lang_mismatch_body,path_lang_name,lang_mismatch_switch,lang_mismatch_dismiss) from the user's preferred language translations.
The shipped layout (routes/layout.ree) renders the dialog and opens it with a one-line showModal() call:
{#if props.path_lang && props.lang_preferred && props.path_lang !== props.lang_preferred }
<dialog id="lang_mismatch_dialog">
<h2>{= props.lang_mismatch_title }</h2>
<p>{= props.lang_mismatch_body } <strong>{= props.path_lang_name }</strong></p>
<form method="dialog">
<button>{= props.lang_mismatch_dismiss }</button>
</form>
<a href="?lang={= props.lang_preferred }">{= props.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 language; their cookie is unchanged.<a href="?lang=<preferred>">- the switch link triggersset_langmiddleware to redirect to the same page in the user's preferred language and update thelangcookie.
The dialog renders in the user's preferred language (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 Language Switch
When ?lang=xx is in the URL, the set_lang middleware does two things before letting the handler run:
- Resolves the canonical path of the current URL (in case the user is on a localised version).
- Builds the localised path in the target language.
- Returns a
302redirect to the new URL, with thelangcookie set, and the?lang=query param stripped.
So /login?lang=sl becomes a redirect to /prijava with a Set-Cookie: lang=sl header. The URL the user sees is clean (no query param), shareable (links stay shareable across languages), and the cookie is updated so future visits use the same language.
The Route-Map API
For programmatic localisation outside templates - building canonical-aware redirects, checking which language a URL belongs to, generating sitemap entries - the route-map module exposes its lookups:
import {
resolve_canonical, // /prijava + "sl" → /login
resolve_localized, // /login + "sl" → /prijava
detect_lang, // /prijava → "sl" (or null)
build_route_maps,
} from "$lib/route_map";
import { localized_url } from "$lib/helpers"; // wraps resolve_localized
localized_url(path, lang) is the handler-side equivalent of the localized_path template helper - it returns the localised path for a given language, preserving query strings. Use it when redirecting:
const lang = get_lang(req);
return Response.redirect(localized_url("/login", lang), 303);
Without localized_url, a Slovenian user who is logged out gets redirected to /login and immediately re-redirects through set_lang 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 language. To skip localisation for a route, do not add route_name rows for its segments.
/api/users/:id will work in every language, 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.