Skip to main content
Urbicon UI

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.

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.