Locale Routing
Carry the locale in the URL (/de/blocks/button) and switch it from the built-in LocaleSwitcher. The package deliberately doesn't own routing — it gives you the onLocaleChange seam; SvelteKit's reroute hook does the rest.
Why routing lives here
Provider & SSR switches the locale in place — no URL change. That is enough for an app whose locale
is a user preference. When the locale should be addressable — shareable /de/… links, distinct pages for crawlers, a browser back-button that walks language
history — it belongs in the URL. That is a routing concern, and routing is a framework decision,
not a component one.
The package owns state, not routes
setLocale mutates the request-scoped locale and fires the provider's onLocaleChange. That callback is the seam: it is where your app
decides what a locale switch means — write a cookie, call another i18n, or (here) navigate.
The path prefix vs. query-param vs. subdomain choice, with its hreflang and canonical
implications, stays yours. This guide wires the recommended path-prefix strategy end to end.
1. Map the URL to a route
SvelteKit's reroute hook runs before handle and turns the visible
URL into the route used for matching. Strip the locale segment there, and your route tree
never needs a [lang] param — /de/blocks/button and /en/blocks/button both render src/routes/blocks/button.
// src/hooks.ts — a universal hook: runs on both server and client, before
// 'handle'. It maps the visible URL to an internal route, so the locale prefix
// stays OUT of your route tree (/blocks/button, not /[lang]/blocks/button).
// reroute must be pure & idempotent — SvelteKit caches it per unique URL.
import type { Reroute } from '@sveltejs/kit';
import { isLocaleSupported } from '@urbicon-ui/i18n';
export const reroute: Reroute = ({ url }) => {
const [, maybeLocale, ...rest] = url.pathname.split('/');
if (isLocaleSupported(maybeLocale)) {
return '/' + rest.join('/'); // '/de/blocks/button' -> '/blocks/button'
}
};reroute does not change the address bar or event.url — it only picks
the route. Because it is cached per URL, keep it pure (no I/O, no Date.now()).
2. Read the locale per request
Since event.url keeps the prefix, the root server load reads the locale straight
from it and feeds the provider — SSR and the first client render agree, with no navigator.language guess. A bare path (first visit, or a legacy unprefixed link)
has no locale to read, so redirect it to the resolveLocale choice — the single point where cookie and Accept-Language still decide.
// src/routes/+layout.server.ts — the locale now lives in the URL, so read it
// from there. reroute does NOT rewrite event.url, so the prefix is still present.
// A bare path (no prefix) is redirected to the cookie/Accept-Language choice.
import { redirect } from '@sveltejs/kit';
import { resolveLocale, isLocaleSupported } from '@urbicon-ui/i18n';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ url, request }) => {
const seg = url.pathname.split('/')[1];
if (!isLocaleSupported(seg)) {
const locale = resolveLocale(request); // cookie -> Accept-Language -> default
const rest = url.pathname === '/' ? '' : url.pathname;
redirect(307, '/' + locale + rest + url.search);
}
return { locale: seg };
};3. Switch from the LocaleSwitcher
Nothing about the switcher changes — the built-in LocaleSwitcher calls setLocale as always. You only translate the resulting onLocaleChange into a navigation to the prefixed URL. That one handler is the whole
routing layer.
<!-- src/routes/+layout.svelte — the routing lives ENTIRELY in onLocaleChange -->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { I18nProvider, isLocaleSupported } from '@urbicon-ui/i18n';
import { localizeHref } from '$lib/i18n-routing';
let { data, children } = $props();
// Drop any locale prefix to get the bare, internal path.
const barePath = (p) => {
const [, seg, ...rest] = p.split('/');
return isLocaleSupported(seg) ? '/' + rest.join('/') : p;
};
</script>
<!-- The built-in <LocaleSwitcher> calls setLocale(); the provider fires
onLocaleChange, and we turn that into a navigation. No switcher code needed. -->
<I18nProvider
locale={data.locale}
onLocaleChange={(l) => {
const target = localizeHref(barePath(page.url.pathname), l);
// Idempotent: recomputing from the current path makes re-fires a no-op.
if (target !== page.url.pathname) goto(target);
}}
>
{@render children()}
</I18nProvider>After the goto lands, the new data.locale flows into the provider as
a controlled value — but it already equals the state setLocale set, so no onLocaleChange re-fires. Locale changes triggered by a plain link (not the
switcher) do fire it, and resolve to the same URL — the target !== current guard makes
that a no-op.
4. Locale-aware links
Internal links must carry the active locale, or a click drops the visitor back to the bare path (and a redirect). Centralize the URL shape in one helper so a strategy change touches one file.
// src/lib/i18n-routing.ts — the one place that knows the URL shape.
import type { Locale } from '@urbicon-ui/i18n';
// Build a locale-prefixed href from an internal (unprefixed) path.
export function localizeHref(path: string, locale: Locale): string {
return '/' + locale + (path === '/' ? '' : path);
}<script lang="ts">
import { localizeHref } from '$lib/i18n-routing';
let { data } = $props();
</script>
<!-- Internal links carry the active locale so navigation stays in-language -->
<a href={localizeHref('/blocks/button', data.locale)}>Button</a>
<a href={localizeHref('/getting-started', data.locale)}>Getting Started</a>5. hreflang & canonical
Addressable locales exist for crawlers — so tell them. Emit an alternate link per
locale and a canonical for the current one. This is the payoff path-prefix routing
buys you over an in-place switch.
<!-- root +layout.svelte head — advertise every locale to crawlers -->
<svelte:head>
{#each ['en', 'de'] as l (l)}
<link
rel="alternate"
hreflang={l}
href={'https://example.com' + localizeHref(barePath(page.url.pathname), l)}
/>
{/each}
<link rel="canonical" href={'https://example.com' + page.url.pathname} />
</svelte:head>Variants
The recipe above always prefixes, including the default — unambiguous, but it gives up a clean
default URL. Two common alternatives, both wired through the same onLocaleChange seam:
Default locale unprefixed
Leave the base locale at the bare path and prefix only the others. Nicer URLs at the cost of
one ambiguity: a crawler can't tell an explicit en page from an undecided one, so
the canonical tag from the previous step does real work here.
// Variant: keep the DEFAULT locale unprefixed (/blocks/button === en),
// prefix only the others (/de/blocks/button). Change two functions:
import { BASE_LOCALE, isLocaleSupported } from '@urbicon-ui/i18n';
// reroute: strip only a NON-base locale prefix
export const reroute = ({ url }) => {
const [, seg, ...rest] = url.pathname.split('/');
if (isLocaleSupported(seg) && seg !== BASE_LOCALE) return '/' + rest.join('/');
};
// localizeHref: no prefix for the base locale
export function localizeHref(path, locale) {
return locale === BASE_LOCALE ? path : '/' + locale + (path === '/' ? '' : path);
}Query parameter
If you don't need SEO-distinct pages, ?lang=de is the lightest option: skip reroute entirely, read url.searchParams.get('lang') in the load, and
in onLocaleChange goto the same path with the param set. Same seam, no route
tree changes — but search engines may treat the variants as one page.