Typed, ICU-aware i18n for SvelteKit 2 + Svelte 5. Path / cookie / domain routing, SSR-safe, full types inferred from your locale files — no codegen.
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 } = createI18n({
mode: 'path',
defaultLanguage: 'en',
seo: true,
languages: {
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')
}
}
});
Only t is returned from createI18n() — it's the one function typed against your schema. Everything else (setLocale, getCurrentLocale, getLocales, getSeoLinks) is schema-agnostic and imported directly from the package.
// 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/server';
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, setLocale, getLocales } from '@plcharriere/svelte-i18n';
import { t } from '../i18n';
</script>
<I18n />
<a href="/">{t('nav.home')}</a>
{#each getLocales() as loc (loc.code)}
<button onclick={() => setLocale(loc.code)}>{loc.nativeLabel}</button>
{/each}
Done. / renders English, /fr renders French, setLocale('fr') client-navigates, <html lang dir> tracks the active locale.
declare global.path / cookie / domain.en-GB → en → default, partial dictionaries supported.intl-messageformat.AsyncLocalStorage.<html lang dir> — patched server-side, kept in sync on the client.| Export | Purpose |
|---|---|
createI18n(config) |
Setup. Returns { t }. |
t(key, params?) |
Typed translator. |
setLocale(code) |
Switch locale, per-mode side effects. |
getCurrentLocale() |
Active locale metadata. |
getLocales() |
All configured locales. |
getSeoLinks(ctx?) |
Canonical / alternates / xDefault. Opt in via seo: true. |
<I18n /> |
Mount once in root layout. |
schema() / typed<T>() |
Locale-file authoring. |
Server entry (@plcharriere/svelte-i18n/server): createI18nHandle(), createI18nReroute(), getRequestLocale(event).
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).
setLocale('fr') client-navigates to the equivalent /fr/... URL, no full reload.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 calls invalidateAll() so server loads re-run in the new locale.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 reject, see domainFallback).createI18n({
mode: 'path', // 'path' | 'cookie' | 'domain'
defaultLanguage: 'en',
languages: { ... },
strict: false, // throw instead of warn on missing keys / params
cookieName: 'locale', // cookie mode only
domainFallback: 'default', // 'default' | 'reject' (domain mode)
seo: false // enable getSeoLinks() output
});
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>
See SPECS.md for the full specification — every requirement, every config option, every warning code.
MIT