SvelteKit Route Prefixing for Multiple Locales

Serving every page under a locale prefix like /de/dashboard in SvelteKit without duplicating the src/routes tree is solved by an optional [[lang]] parameter combined with a reroute hook that strips the prefix before matching. The naive approach — copying every route into per-language folders, or wrapping everything in a required [lang] segment — produces a build where /dashboard and /de/dashboard resolve to different files, breaks unprefixed default-locale URLs, and silently omits localized variants from the prerender manifest. This page walks through the single-tree pattern: one [[lang]] group that handles all locales, a reroute hook that normalizes the path, explicit prerender entries so every locale is emitted at build time, and the <link rel="canonical"> plus hreflang tags that keep the localized URLs crawlable.

The technique builds directly on the locale resolution layer described in SvelteKit Internationalization Basics; here we focus only on the URL-prefix mechanics.

Optional [[lang]] prefix routing flow A request to /de/dashboard passes through the reroute hook, which strips the lang prefix to /dashboard so a single [[lang]] route matches; at build time the prerender entries fan that one route out into one HTML file per supported locale. Incoming request /de/dashboard reroute hook strip lang prefix → /dashboard single route [[lang]]/dashboard +page.svelte prerender entries /dashboard en (default) /de/dashboard de /fr/dashboard fr /es/dashboard es
One [[lang]] route serves every locale; the reroute hook strips the prefix before matching, and prerender entries fan it out into per-locale HTML.

Root Cause: Why a Required [lang] Segment Breaks

SvelteKit matches routes against the literal pathname. A required dynamic parameter src/routes/[lang]/dashboard/+page.svelte only matches paths that have a first segment — so /de/dashboard works but /dashboard (the default-locale URL with no prefix) returns a 404. The common workaround is to redirect every unprefixed request to /en/..., but that pollutes the history stack, doubles request count for the most common locale, and forces the canonical URL to carry a redundant /en prefix.

The correct primitive is the optional parameter [[lang]] (double brackets). A folder named src/routes/[[lang]]/ matches both /dashboard (where params.lang is undefined) and /de/dashboard (where params.lang is "de"). One route file, every locale, with the default locale served prefix-free.

The remaining problem is validation: [[lang]] will happily match /banana/dashboard and set params.lang = "banana". Without intervention, any garbage first segment swallows the real route. This is where the reroute hook earns its place — it runs before routing and lets you rewrite the pathname the router sees, so an unknown prefix can be treated as part of the unprefixed path rather than a locale.

Minimal Reproducible Example

The directory below is the entire route tree for any number of locales:

src/routes/
├── +layout.svelte          # reads params.lang, sets <html lang>
├── +layout.ts              # loads the locale dictionary
└── [[lang]]/
    ├── +page.svelte
    └── dashboard/
        └── +page.svelte

A first attempt without the hook fails the moment a non-locale segment appears:

// src/routes/[[lang]]/+layout.ts  — BROKEN without a reroute hook
export const load = ({ params }) => {
  // For /banana/dashboard, params.lang === "banana".
  // The dashboard route never matches; you get the [[lang]] index instead.
  return { lang: params.lang ?? 'en' };
};

Visiting /de/dashboard works, but /dashboard/extra or a typo’d /dr/dashboard silently misroutes because dr is accepted as a locale.

The Fix: reroute Hook + Optional Param

The reroute hook (added in SvelteKit 2.3) runs in src/hooks.ts — the universal hooks file, so it executes on both server and client navigations. It returns the pathname the router should match against. Strip a valid locale prefix so the route tree only ever deals with the unprefixed path; leave anything else untouched.

// src/lib/locales.ts
export const SUPPORTED = ['en', 'de', 'fr', 'es'] as const;
export const DEFAULT_LOCALE = 'en';
export type Locale = (typeof SUPPORTED)[number];

export function isLocale(seg: string | undefined): seg is Locale {
  return SUPPORTED.includes(seg as Locale);
}
// src/hooks.ts  — universal reroute, runs on server AND client
import type { Reroute } from '@sveltejs/kit';
import { isLocale } from '$lib/locales';

export const reroute: Reroute = ({ url }) => {
  const [, first, ...rest] = url.pathname.split('/');
  // Only strip the segment if it is a real, supported locale.
  if (isLocale(first)) {
    // Router now matches as if the prefix were never there:
    // /de/dashboard -> /dashboard  (matches [[lang]]/dashboard cleanly)
    return '/' + rest.join('/');
  }
  // Unknown prefix or no prefix: leave it; the router matches the
  // unprefixed tree, and /banana/... 404s instead of being mistaken
  // for a locale.
  return url.pathname;
};

With the prefix stripped before matching, params.lang inside [[lang]] is no longer how you read the active locale — the reroute removed it from what the router sees. Read it from the original URL in the layout instead, so server and client agree:

// src/routes/[[lang]]/+layout.ts
import { isLocale, DEFAULT_LOCALE } from '$lib/locales';
import type { LayoutLoad } from './$types';

export const load: LayoutLoad = async ({ url }) => {
  const first = url.pathname.split('/')[1];
  const lang = isLocale(first) ? first : DEFAULT_LOCALE;
  // Load only the dictionary for the active locale — no bundle bloat.
  const messages = (await import(`$lib/i18n/${lang}.json`)).default;
  return { lang, messages };
};

Because reroute is universal, a client-side goto('/de/dashboard') and a cold server request to the same URL both resolve through identical logic, which is what eliminates the hydration mismatch that prefix routing is notorious for.

Generating Prerender Entries Per Locale

If you prerender (static adapter or export const prerender = true), SvelteKit only emits HTML for URLs it can discover by crawling links. A [[lang]] route is dynamic, so the build emits the unprefixed page (/dashboard) but skips /de/dashboard, /fr/dashboard, and /es/dashboard unless you list them. Declare them explicitly with the entries export so every locale gets its own static file:

// src/routes/[[lang]]/dashboard/+page.ts
import { SUPPORTED, DEFAULT_LOCALE } from '$lib/locales';
import type { EntryGenerator } from './$types';

export const prerender = true;

// Emit /dashboard (default, prefix-free) plus /de, /fr, /es variants.
export const entries: EntryGenerator = () =>
  SUPPORTED.map((lang) => ({
    lang: lang === DEFAULT_LOCALE ? undefined : lang,
  }));

Returning lang: undefined for the default locale produces the prefix-free /dashboard; the others produce /de/dashboard and so on. The params you return here are matched against the route before reroute runs at request time, so they must include the prefix you want in the output URL.

Canonical and hreflang Tags

Each localized URL must declare a single canonical and a reciprocal hreflang set, or the localized variants compete as duplicates. Emit them from the layout using the active locale and the prefix convention. Build the prefix-free URL for the default locale to match what the prerender step produced:





  
  {#each SUPPORTED as l}
    
  {/each}
  

The x-default alternate points crawlers at the prefix-free default-locale URL — the same one the fallback chain resolves to when no locale preference is expressed.

Verification

Confirm the build emits one HTML file per locale and that the prefix-free default exists. After npm run build with the static adapter:

# Static adapter writes to build/ ; expect one file per locale.
$ find build -path '*dashboard*' -name '*.html'
build/dashboard.html        # en, prefix-free
build/de/dashboard.html
build/fr/dashboard.html
build/es/dashboard.html

A focused test asserts the reroute strips only valid prefixes:

import { describe, it, expect } from 'vitest';
import { reroute } from '../src/hooks';

const run = (p: string) => reroute({ url: new URL(`https://x${p}`) } as any);

describe('reroute', () => {
  it('strips a supported locale prefix', () => {
    expect(run('/de/dashboard')).toBe('/dashboard');
  });
  it('leaves an unknown first segment intact', () => {
    expect(run('/banana/dashboard')).toBe('/banana/dashboard');
  });
  it('passes the prefix-free default through', () => {
    expect(run('/dashboard')).toBe('/dashboard');
  });
});

When to Escalate

The [[lang]] + reroute pattern assumes path-segment prefixes (/de/...). If your localization strategy requires per-locale domains (example.de) or localized path segments (/de/uebersicht for /en/about), the reroute hook alone is not enough — you need a translated-route map and adapter-level domain routing, which is a configuration problem rather than a routing one. For per-locale slug translation, pair this with the parent cluster SvelteKit Internationalization Basics, and if cookie-based locale selection is also in play see the cookie-persistence guide linked below before adding a second source of truth.

FAQ

Why use [[lang]] instead of [lang]?

The double-bracket optional parameter matches both /dashboard and /de/dashboard from a single route file, so the default locale is served prefix-free while other locales carry a prefix. A required [lang] only matches prefixed paths, forcing every default-locale URL to either 404 or redirect through a redundant /en prefix.

Does the reroute hook run on the client too?

Yes. reroute lives in src/hooks.ts, the universal hooks file, so it runs during both server-rendered requests and client-side goto navigations. That shared execution path is what keeps server and client agreeing on the resolved route and prevents hydration mismatches after a locale switch.

Why are my localized pages missing after a static build?

A [[lang]] route is dynamic, so the prerenderer only emits URLs it can crawl — typically just the unprefixed default. Export an entries generator that returns one params object per supported locale (with lang: undefined for the prefix-free default) so the build emits /de/dashboard, /fr/dashboard, and the rest.

Part of SvelteKit Internationalization Basics.