SvelteKit Internationalization Basics

SvelteKit i18n breaks the moment a load function returns a different locale than hooks.server.ts resolved — you ship /de/ HTML that hydrates as English and the console logs Hydration failed because the server rendered text didn't match. This guide builds a deterministic locale layer: a single source of truth in hooks.server.ts, a [[lang]] optional route parameter, message loading inside load, and a reactive store that survives client navigation without a flash of the wrong language.

The core problem is ordering. SvelteKit runs your server hook first, then layout/page load functions, then hydrates on the client. If locale is decided in more than one of those places — or decided lazily in a component — the server and client disagree and Svelte re-renders the entire tree. Pin the locale once, on the server, propagate it through event.locals, and let everything downstream read that value.

SvelteKit locale propagation across the request lifecycle hooks.server.ts resolves the locale from URL param, cookie, and Accept-Language, writes it to event.locals, the root load reads locals and loads messages, the page renders server-side, then the client hydrates from the same locale without a mismatch. SERVER hooks.server.ts resolve locale event.locals locale = "de" +layout.server load messages SSR HTML lang="de" stream CLIENT hydrate same locale $locale store reactive re-render client navigation re-runs load → new messages, no full reload MISMATCH RISK Decide locale in a component instead of the hook → server renders "en", client renders "de" → hydration error.
Locale is resolved once on the server and propagated through event.locals so SSR and hydration agree.

Prerequisites

Concept & spec: what “locale” means in SvelteKit

A locale here is a BCP 47 language tag (en, de-CH, pt-BR) as defined by RFC 5646, matched against your supported set using the lookup algorithm of RFC 4647. SvelteKit itself is locale-agnostic — it gives you three extension points and you decide what locale means in each: hooks.server.ts (per-request server resolution), the [[lang]] optional route parameter (URL-encoded locale), and load functions (data/message loading). This page is part of the broader Framework i18n & Component Routing area, which compares how each framework wires those same three concerns.

The discipline that keeps SvelteKit i18n stable: resolve in exactly one place (the hook), validate against your supported set, and never re-derive locale downstream. Everything else — load, components, the <html lang> attribute — consumes the resolved value. Message formatting that uses plurals or gendered forms should follow ICU MessageFormat and CLDR plural categories so the same catalogs are portable across your stack.

Two SvelteKit specifics make this harder than in a single-process framework. First, load functions run in two environments — +layout.server.ts is server-only, while +layout.ts (no .server) runs on the server during SSR and again on the client during hydration and navigation. Locale must reach the universal load through the data returned by the server load, otherwise the client load has nothing to read and falls back to a default. Second, SvelteKit streams the response: transformPageChunk rewrites HTML as it flushes, so the <html lang> stamp must be a placeholder substitution rather than a value you compute after the fact. Get those two right and the rest of the pipeline — message registration, the reactive store, the switcher — is mechanical.

Step-by-step implementation

1. Type App.Locals and define the supported set

Give the locale a real type so every event.locals.locale read is checked. This is the contract the rest of the app depends on.

// src/app.d.ts
import type { Locale } from '$lib/i18n';
declare global {
  namespace App {
    interface Locals {
      locale: Locale;
    }
  }
}
export {};
// src/lib/i18n.ts
export const locales = ['en', 'de', 'fr'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
export const isLocale = (x: string | undefined): x is Locale =>
  !!x && (locales as readonly string[]).includes(x);

2. Resolve the locale once in hooks.server.ts

Apply a fixed priority order: explicit URL param → persisted cookie → Accept-Language → default. Write the result to event.locals and stamp the <html lang> attribute via transformPageChunk so SSR output is correct on the first byte.

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { isLocale, defaultLocale, locales } from '$lib/i18n';

function negotiate(header: string | null) {
  const wanted = (header ?? '')
    .split(',')
    .map((p) => p.split(';')[0].trim().split('-')[0]);
  return wanted.find((w) => isLocale(w)) ?? defaultLocale;
}

export const handle: Handle = async ({ event, resolve }) => {
  const fromParam = event.params.lang;                 // [[lang]] segment
  const fromCookie = event.cookies.get('locale');
  const locale = isLocale(fromParam)
    ? fromParam
    : isLocale(fromCookie)
      ? fromCookie
      : negotiate(event.request.headers.get('accept-language'));

  event.locals.locale = locale;
  return resolve(event, {
    transformPageChunk: ({ html }) => html.replace('%lang%', locale),
  });
};

Put <html lang="%lang%"> in src/app.html so the replacement targets a placeholder, not a hardcoded en.

3. Add the [[lang]] optional route parameter

Wrap routes in an optional [[lang]] parameter so /about (default locale, no prefix) and /de/about both resolve. A param matcher rejects unknown segments before they hit your pages, avoiding the route-collision class of bugs.

// src/params/lang.ts
import { isLocale } from '$lib/i18n';
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => isLocale(param);

Then move your routes under src/routes/[[lang=lang]]/. The matcher name (lang) maps to the file src/params/lang.ts. Prefix strategy and prerendering are covered in depth in SvelteKit route prefixing for multiple locales.

4. Load messages in +layout.server.ts and +layout.ts

Load messages where the locale already lives. With paraglide you only need to pass the locale down; with runtime catalogs (svelte-i18n / typesafe-i18n) the server load returns the dictionary and the universal load registers it so the client has it before hydration.

// src/routes/[[lang=lang]]/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
  return { locale: locals.locale };
};
// src/routes/[[lang=lang]]/+layout.ts  — runs on server AND client
import { setLocale } from '$lib/paraglide/runtime';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ data }) => {
  setLocale(data.locale, { reload: false }); // sync paraglide runtime to resolved locale
  return { locale: data.locale };
};

5. Render with a reactive locale-aware message call

In components, call the message function directly (paraglide) or read the translation store ($_ from svelte-i18n). Either way the locale comes from load data, not from a component-local decision, so SSR and hydration agree.




{m.welcome_title()}

{m.greeting({ name: 'Ada' })}

active locale: {data.locale}

6. Switch locale without a full reload

A switcher writes the cookie and navigates to the prefixed URL. Because the hook re-resolves and load re-runs on client navigation, the page swaps messages reactively — no hard refresh. Cookie persistence has subtle path/sameSite traps; if a switch reverts on the next request see SvelteKit locale cookie not persisting.



{#each locales as loc}
  
{/each}

Configuration reference

Option Type Description / default
locales readonly Locale[] Canonical supported set; the single import everything validates against. No default — you must define it.
defaultLocale Locale Locale used when negotiation finds no match. Typically 'en'.
[[lang=lang]] matcher ParamMatcher Rejects unknown URL segments before routing; returns false for unsupported tags.
locale cookie path=/; sameSite=lax; max-age=31536000 Persists explicit user choice across requests. path=/ is mandatory or it scopes to one route.
transformPageChunk ({ html }) => string Stamps <html lang> in SSR output; replace a %lang% placeholder, not a literal locale.
paraglide strategy string[] e.g. ['url', 'cookie', 'preferredLanguage', 'baseLocale'] — paraglide’s own resolution order if you delegate negotiation to it.
reload (setLocale) boolean false to switch in-place via runes; true to force a full document reload.

Framework variants

Next.js. The App Router resolves locale in middleware.ts and exposes it through [locale] segments — the same “resolve early, propagate down” pattern with a different mechanism; see Next.js i18n routing setup.

Vue / Nuxt. Nuxt uses a server middleware plus vue-i18n composition stores; the reactive store maps to SvelteKit’s $locale/message functions. The Vue i18n Composition API guide shows the store-driven equivalent.

Node backend / API routes. SvelteKit +server.ts endpoints read event.locals.locale exactly like pages, so API responses (error messages, emails) localize from the same resolved value — no second negotiation path.

paraglide vs runtime catalogs. paraglide compiles messages to tree-shakeable functions (smaller bundles, full type safety, zero runtime parser); svelte-i18n/typesafe-i18n load JSON at runtime (simpler for designer-edited catalogs, larger client payload). Pick paraglide when bundle size and type safety dominate; pick a runtime lib when non-developers hand-edit catalogs frequently.

Cross-cutting concerns

Edge caching. If you serve the default locale at the bare path and prefixed locales separately, the unprefixed path is cacheable by URL alone, but any response whose body varies by Accept-Language must send Vary: Accept-Language or a CDN will serve one visitor’s language to the next. Prefer URL-prefixed locales precisely because /de/about is a distinct, safely cacheable key — negotiation only runs on the bare default path. Set Cache-Control per-route in +layout.server.ts via setHeaders so localized pages don’t leak across the cache.

Accessibility. The <html lang> attribute is not cosmetic: screen readers switch pronunciation rules from it, and it drives browser translation prompts. Stamping it server-side (step 2) guarantees assistive tech gets the right language on first paint rather than after a client correction. For right-to-left locales, also set dir on the same element from the resolved locale.

SEO and crawlability. Emit hreflang alternates pointing at each prefixed URL so crawlers map the localized variants to one another, and make sure each locale’s route returns a real 200 (not a client redirect) — the CI probe below enforces that.

Verification

Assert that a prefixed request both stamps <html lang> and renders translated copy, then add an HTTP probe in CI.

// tests/locale.test.ts  (vitest)
import { expect, test } from 'vitest';
test('de route serves German SSR with correct lang attr', async () => {
  const res = await fetch('http://localhost:4173/de/about');
  const html = await res.text();
  expect(html).toContain('<html lang="de"');
  expect(html).toMatch(/Willkommen|Über/);     // translated, not the en fallback
});
# CI gate: every supported locale must return 200 and the right lang attribute
for loc in en de fr; do
  code=$(curl -s -o /tmp/out.html -w '%{http_code}' "http://localhost:4173/$loc/about")
  grep -q "<html lang=\"$loc\"" /tmp/out.html && [ "$code" = 200 ] \
    || { echo "FAIL $loc ($code)"; exit 1; }
done

Common pitfalls

  • Hydration mismatch after switching. Locale decided in a component (navigator.language) instead of the hook — server renders en, client renders de. Fix: read data.locale only. The same failure on Next.js is dissected in hydration mismatch after locale switch.
  • Cookie reverts every request. Missing path=/ scopes the cookie to the current route, so the next navigation can’t read it — see locale cookie not persisting.
  • /de/about 404s on a static host. Optional [[lang]] paths weren’t enumerated for prerender; declare entries() or set prerender per the route prefixing guide.
  • Flash of default locale. Messages loaded in onMount instead of load; move loading into +layout.ts so they exist before first paint.
  • Untranslated keys render raw IDs. No fallback wired; route missing keys through a graceful fallback chain to the default locale.

FAQ

Should I use inlang/paraglide-js or svelte-i18n / typesafe-i18n?

Use paraglide when you want compile-time type safety, tree-shaken message functions, and the smallest client bundle — messages become callable functions checked at build time. Use svelte-i18n or typesafe-i18n when non-developers edit JSON/YAML catalogs frequently and runtime flexibility matters more than bundle size. Both fit the same hooks.server.ts + load propagation pattern; only the message-call syntax differs.

Do I need [[lang]] (optional) or [lang] (required) route params?

Use the optional [[lang]] form when your default locale serves at the bare path (/about) and other locales are prefixed (/de/about) — better for the default-locale’s clean URLs. Use required [lang] when every locale must be prefixed (/en/about, /de/about), which is simpler to reason about for prerendering and sitemaps but loses the unprefixed default.

Why does my page hydrate as English even though the URL is /de?

Something downstream of the hook re-decided the locale — usually a component reading navigator.language or a load that defaults to 'en' before reading locals.locale. Resolve once in hooks.server.ts, write event.locals.locale, and have every load and component read only that value so SSR and client hydration use identical input.

Where should message loading happen — load or the component?

In a load function (+layout.ts running on both server and client), never in onMount. Loading in onMount runs only after hydration, causing a flash of the default locale and a hydration mismatch. load guarantees messages are present for SSR and for the first client paint.

How does locale switching avoid a full page reload?

The switcher sets the cookie and calls goto() with invalidateAll: true. SvelteKit re-runs hooks.server.ts (re-resolving locale) and the load functions for the new URL, then patches the DOM reactively. With paraglide you additionally call setLocale(locale, { reload: false }) so message functions return the new language in-place.

Part of Framework i18n & Component Routing.