--- createdAt: 2026-09-02 updatedAt: 2026-09-02 title: "SvelteKit i18n: routing, SSR, and shared state" description: SvelteKit ships no i18n. How to wire locale routing with an optional param or the reroute hook, and why a module-level locale store leaks between SSR requests. keywords: - sveltekit i18n - SvelteKit internationalization - svelte-i18n - Paraglide - reroute hook - hooks.server.ts - locale routing - Svelte 5 runes slugs: - blog - i18n-technologies - frameworks - sveltekit author: aymericzip --- # SvelteKit i18n: routing, SSR, and the state you cannot share SvelteKit has no built-in i18n. You pick a message library, then you wire locale routing and locale detection into `load` and the server hooks yourself, and that second half is where SvelteKit differs from a plain SPA. This post covers the two routing options, the SSR bug that only shows up under real traffic, and how the current libraries compare. ## Table of Contents ## The bug you will not see in dev Start with the failure mode, because a lot of SvelteKit i18n snippets you will find contain it. ```ts fileName="src/routes/[[lang]]/+layout.ts" import { locale, waitLocale } from "svelte-i18n"; export const load = async ({ params }) => { locale.set(params.lang ?? "en"); await waitLocale(); }; ``` `locale` is a store created once, at module scope. On the client that is fine: one browser, one user, one value. On the server, the module is evaluated once per process and every concurrent request reads and writes the same store. So request A sets `fr` and yields at an `await`. Request B sets `en`. Request A resumes and renders English. You will never reproduce it locally, because your dev server handles one request at a time and you are one user. It shows up as a bug report saying "the page was in the wrong language once" that nobody can reproduce either. This is not specific to i18n: SvelteKit's own docs warn about server-side shared state in general. A current-locale store is just the most common way for an app to trip over it. ## The shape that works Resolve the locale once per request, put it on `event.locals`, hand it to the component tree through `load` data. Nothing about the current user is ever stored in a module. ```ts fileName="src/hooks.server.ts" import type { Handle } from "@sveltejs/kit"; import { detectLocale } from "$lib/detectLocale"; export const handle: Handle = async ({ event, resolve }) => { const locale = detectLocale(event); // URL segment, then cookie, then Accept-Language event.locals.locale = locale; return resolve(event, { transformPageChunk: ({ html }) => html.replace("%lang%", locale), }); }; ``` ```ts fileName="src/routes/+layout.server.ts" export const load = ({ locals }) => ({ locale: locals.locale }); ``` Two details are easy to skip. `event.locals` needs a type in `src/app.d.ts` or `locals.locale` is `any`. And `%lang%` only works if `src/app.html` actually contains ``, which is the single line that decides whether screen readers and search engines see the right language. ## Routing: `[[lang]]` versus `reroute` The old approach is an optional route parameter. You create `src/routes/[[lang]]/` and move the entire app inside it, with a matcher in `src/params/lang.ts` so unknown prefixes 404 instead of being treated as a page slug. The newer approach is the `reroute` hook. The URL the visitor sees keeps its locale prefix, but SvelteKit matches it against a route tree that has no locale segment at all. ```ts fileName="src/hooks.ts" import type { Reroute } from "@sveltejs/kit"; const locales = ["en", "fr", "es"]; export const reroute: Reroute = ({ url }) => { const [, first, ...rest] = url.pathname.split("/"); if (locales.includes(first)) { return `/${rest.join("/")}`; } }; ``` | Concern | `[[lang]]` optional param | `reroute` hook | | :-------------------------- | :------------------------------------- | :--------------------------------------------------------- | | Route tree | everything nested one directory deeper | untouched, `/about` stays `src/routes/about` | | Where the locale comes from | `params.lang` in every `load` | `event.locals`, passed down as `load` data | | Adding a page | must live inside the group | anywhere | | Client-side navigation | handled by the router | `src/hooks.ts` is universal, so it runs in the browser too | | Requirement | any version | SvelteKit 2.3+ | `reroute` is usually the better answer now, for one reason: your route directory stops encoding a routing concern. Nested layouts, `+error.svelte` and every relative path go back to meaning what they say, and a contributor who has never touched i18n can add a page without knowing about the group. The honest cost: `params.lang` disappears, so anything that used it reads `data.locale` instead, and the prerenderer finds pages by crawling links, so `/fr/about` is only prerendered if something links to it. Whichever you pick, links still need the prefix added by hand, and a locale switcher should render as `` pointing at the localized URL. Crawlers do not click buttons. See the [hreflang guide](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/hreflang_guide_multilingual_seo.md) for the tags that go with it. ## Svelte 5 runes change the syntax, not the sharing In runes mode, reactive local state is `$state`, derived values are `$derived`, and `$:` is gone. It is tempting to read that as "stores are legacy, use runes", and then to write this in a `.svelte.ts` file: ```ts fileName="src/lib/locale.svelte.ts" export const appLocale = $state({ value: "en" }); // still module scope, still shared ``` That has exactly the same SSR problem as the store above. A rune declared at module level is one value for the whole server process. Runes are per component instance only when they are declared inside a component. The container that is genuinely per request is Svelte's context API: `setContext` in the root layout, `getContext` wherever you read. Context lives in the component tree, and the server builds one tree per request. ## The library options | Library | Message format | Where the locale lives | Note | | :----------------- | :---------------------------------------------- | :---------------------------------------- | :----------------------------------------------------------------- | | `svelte-i18n` | ICU, via `intl-messageformat` | module-level stores (`$locale`, `$_`) | Most adopted, well documented, SSR wiring is on you | | Paraglide (inlang) | own syntax, compiled to plain functions | read per call from URL, cookie or storage | Best tree-shaking story, generated files live in your repo | | `typesafe-i18n` | own syntax, generated typed accessors | you wire it | Strong types, no key strings, but you assemble the SvelteKit parts | | Intlayer | `t()` in `.content.ts` files, compiled at build | Svelte context, set from `load` data | Build plugin required, smaller ecosystem | Paraglide deserves a fair reading. Compiling each message into its own exported function means the bundler can drop the ones a route never calls, which is a better lever than any runtime lazy-loading scheme. The [Svelte benchmark](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/benchmark/svelte.md) confirms the tree-shaking works as advertised in a Vite plus Svelte app. The trade-offs are also real: generated JavaScript is committed to the repo and regenerated before every push, which produces merge conflicts on shared branches, and because it resolves the locale per call instead of from a store, reactivity on locale change costs more work than it should. The same benchmark puts `svelte-i18n` at roughly **15.9 kB** after bundling and minification, about **7×** `svelte-intlayer`. That number is the library itself, before any of your content, which is the part most comparisons leave out. ## Intlayer on SvelteKit Intlayer declares content in a file next to the component that renders it, and a Vite plugin compiles those declarations into per-component dictionaries at build time. ```ts fileName="src/routes/home.content.ts" import { t, type Dictionary } from "intlayer"; const homeContent = { key: "home", content: { title: t({ en: "Welcome to SvelteKit", fr: "Bienvenue sur SvelteKit", es: "Bienvenido a SvelteKit", }), }, } satisfies Dictionary; export default homeContent; ``` The root layout calls `setupIntlayer` with the locale it received from `load`. That call uses Svelte's context, so the locale is scoped to the request's component tree rather than to a module. The official template uses the optional-param route group, but nothing stops you from feeding it `locals.locale` from a `reroute` setup instead. ```svelte fileName="src/routes/[[locale=locale]]/+layout.svelte" {@render children()} ``` `useIntlayer` returns a Svelte store, so components read it with the `$` prefix: ```svelte fileName="src/routes/[[locale=locale]]/+page.svelte" {$content.title.value}

{$content.title}

``` Server-side detection is the `handle` hook from earlier, with `getLocaleFromStorage` and `localeDetector` doing the cookie and `Accept-Language` work, and `getLocalizedUrl` redirecting an unprefixed path. The [SvelteKit setup guide](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/intlayer_with_svelte_kit.md) has the full file.