Typed, ICU-aware, SSR-safe i18n for SvelteKit 2 + Svelte 5 with per-route scoping.
t('cart.items', { count: 2 }); // ✅ typed from the schema
t('cart.items', { count: '2' }); // ❌ count: number
t('car.items'); // ❌ autocomplete catches the typo
npm install @plcharriere/svelte-i18n
pnpm add @plcharriere/svelte-i18n
yarn add @plcharriere/svelte-i18n
bun add @plcharriere/svelte-i18n
// src/locales/en.ts
import { schema, typed } from '@plcharriere/svelte-i18n';
export default schema({
nav: { home: 'Home', about: 'About' },
cart: {
items: typed<{ count: number }>(
'{count, plural, one {# item} other {# items}}'
)
}
});
// src/i18n.ts
import { createI18n } from '@plcharriere/svelte-i18n';
export const {
t,
setLocale,
getCurrentLocale,
getDefaultLocale,
getLocales,
isLoadingLocale,
getLoadingLocale
} = createI18n({
mode: 'path',
defaultLocale: 'en',
locales: {
en: {
label: 'English',
nativeLabel: 'English',
load: () => import('./locales/en')
},
fr: {
label: 'French',
nativeLabel: 'Français',
load: () => import('./locales/fr')
},
'en-GB': {
label: 'English (UK)',
parent: 'en',
load: () => import('./locales/en-GB')
},
ar: {
label: 'Arabic',
nativeLabel: 'العربية',
rtl: true,
load: () => import('./locales/ar')
}
}
});
createI18n() returns the typed locale bundle: t (typed against your schema), and setLocale, getCurrentLocale, getDefaultLocale, getLocales, isLoadingLocale, getLoadingLocale — all typed against the locale codes you configured. getSeoLinks is schema-agnostic and imported directly from the package.
Each helper is also re-exported from @plcharriere/svelte-i18n directly with loose string typing — handy if you don't need strict type-checking on locale codes:
// Typed (recommended) — destructured from createI18n
import { setLocale } from './i18n';
setLocale('xx'); // ❌ TS error: not in your locales
// Untyped (escape hatch)
import { setLocale } from '@plcharriere/svelte-i18n';
setLocale('xx'); // ✓ compiles; no-op + warn at runtime
// src/hooks.server.ts
import './i18n';
import { createI18nHandle } from '@plcharriere/svelte-i18n/server';
export const handle = createI18nHandle();
// src/hooks.ts — path mode only
import './i18n';
import { createI18nReroute } from '@plcharriere/svelte-i18n';
export const reroute = createI18nReroute();
// src/app.d.ts
import type { I18nLocals } from '@plcharriere/svelte-i18n/server';
declare global {
namespace App {
interface Locals {
i18n: I18nLocals;
}
}
}
export {};
// src/routes/+layout.server.ts
import { getSeoLinks } from '@plcharriere/svelte-i18n';
export const load = ({ locals, url }) => ({
i18n: {
...locals.i18n,
seo: getSeoLinks({ url, locale: locals.i18n.locale })
}
});
<!-- src/routes/+layout.svelte -->
<script>
import { I18n } from '@plcharriere/svelte-i18n';
import { t, setLocale, getLocales } from '../i18n';
</script>
<I18n />
<a href="/">{t('nav.home')}</a>
{#each getLocales() as locale (locale.code)}
<button onclick={() => setLocale(locale.code)}>{locale.nativeLabel}</button>
{/each}
Done. / renders English, /fr renders French, setLocale('fr') client-navigates, <html lang dir> tracks the active locale.
path / cookie / domain.en-GB → en → default, partial dictionaries supported.intl-messageformat.<html lang dir> tracks the active locale, on the server and client.fr.ts and every visible translation updates in place. No reload, no state loss.| Export | Purpose |
|---|---|
createI18n(config) |
Setup. Returns the typed bundle (t, setLocale, getCurrentLocale, getDefaultLocale, getLocales, isLoadingLocale, getLoadingLocale). |
t(key, params?) |
Typed translator. |
setLocale(code) |
Switch locale, per-mode side effects. |
getCurrentLocale() |
Active locale metadata. |
getDefaultLocale() |
Default locale metadata (the one configured via defaultLocale). |
getLocales() |
All configured locales. |
isLoadingLocale(code?) |
Reactive: true while a setLocale is in flight. With code, only true while switching to that specific locale. |
getLoadingLocale() |
Reactive: the locale currently being switched to, or undefined. |
getSeoLinks(ctx?) |
Canonical / alternates / xDefault. On by default; pass seo: false to disable. |
<I18n /> |
Mount once in root layout. |
schema() / typed<T>() |
Locale-file authoring. |
The locale helpers (setLocale and friends) are re-exported standalone from @plcharriere/svelte-i18n with loose string typing — use those if you don't need locale-code type-checking.
Server entry (@plcharriere/svelte-i18n/server): createI18nHandle({ keyManifest? }).
Vite entry (@plcharriere/svelte-i18n/vite): svelteI18n() — see Per-route scoping.
Pick how the active locale is determined on each request. Set via mode on createI18n().
pathThe locale is the first URL segment: /en/about, /fr/about. The default language can optionally be served unprefixed (/about).
Best for: SEO-critical sites — each translation has a distinct, crawlable URL.
Switching: setLocale('fr') client-navigates to the equivalent /fr/... URL, no full reload.
Internal links: write <a href="/about"> as-is. The library rewrites unprefixed internal hrefs to carry the active locale — both in the SSR HTML (so crawlers, hover previews, copy-link, and middle-click see /fr/about) and in the DOM after a client-side switch. Default-locale pages stay unprefixed.
Default-locale prefix (/en/... when default is en): controlled by defaultLocalePath:
'redirect' (default) — 301 from /en/about to /about so there's one canonical URL per page.'allow' — both /about and /en/about render. Two URLs serve the same content.'404' — /en/about returns Not Found. Strictest canonical.Note: SvelteKit's per-route trailingSlash policy runs before this redirect, so visiting /en (no trailing slash) on a route configured with trailingSlash = 'always' will hit two redirects: SvelteKit's 308 to /en/, then our 301 to /. Either accept the cascade or set trailingSlash = 'ignore'.
cookieURLs stay the same across locales (/about). The active locale is read from a cookie (locale by default), with ?lang=xx as a one-shot override that also writes the cookie.
setLocale('fr') writes the cookie and re-runs server loads in the new locale.setLocale, every other tab on the same origin updates automatically via BroadcastChannel. Toggle with syncTabs: false; rename the channel with syncChannel: 'my-app' if you need to isolate from another app sharing the origin.domainThe locale is picked by event.url.host. Each language declares one or more domains: ['example.fr', 'fr.example.com'].
setLocale('fr') navigates to the configured domain for fr. Unmapped hosts fall back to the default (or 404, see domainFallback).createI18n({
mode: 'path', // 'path' | 'cookie' | 'domain' (defaults to 'path')
defaultLocale: 'en',
defaultLocalePath: 'redirect', // 'redirect' | 'allow' | '404' — what to do with `/en/...` (path mode)
locales: { ... },
strict: false, // throw instead of warn on missing keys / params
cookieName: 'locale', // cookie mode only
domainFallback: 'default', // 'default' | '404' (domain mode)
seo: true, // pass `false` to suppress getSeoLinks() output
// cookie mode only — cross-tab locale sync via BroadcastChannel
syncTabs: true, // disable with `false`
syncChannel: 'svelte-i18n' // override if multiple apps share the origin
});
By default every request ships the full dictionary for the active locale plus its fallback chain. For a marketing + app + admin codebase that's wasteful — visitors to / don't need the admin strings, and ungated copy shouldn't leak via view-source.
Opt into per-route pruning by adding the Vite plugin:
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteI18n } from '@plcharriere/svelte-i18n/vite';
export default {
plugins: [svelteI18n(), sveltekit()]
};
That's it. Your existing hooks.server.ts stays exactly as it was — createI18nHandle() with no arguments picks up the per-route manifest automatically.
What you get: /cart only ships cart.*, nav.*, common.*, profile.*. Zero bytes from admin, home, seo, etc.
Limitations: dynamic keys (t(someVar)) can't be discovered. Reference them as literals somewhere on the route — e.g. t('errors.generic') — to force them into the shipped set, or accept they'll resolve from the fallback chain instead.
Dev HMR: edit a locale file and translations swap in place. No reload, no state loss.
t() is reactive only if you don't capture it.
<!-- ❌ stays in initial locale forever -->
<script>
const label = t('cart.addToCart');
</script>
<!-- ✅ updates on locale change -->
<script>
const label = $derived(t('cart.addToCart'));
</script>
<!-- ✅ inline is already reactive -->
<button>{t('cart.addToCart')}</button>
Svelte 5 flags the broken pattern with a state_referenced_locally warning. Listen to it: wrap in $derived or call t() inline in the template.
See SPECS.md for the full specification — every requirement, every config option, every warning code.
MIT