A lightweight, powerful internationalization (i18n) library designed specifically for SvelteKit. This package combines @sveltekit-i18n/base with @sveltekit-i18n/parser-curly to provide the quickest way to add multilingual support to your SvelteKit applications.
- 🚀 SvelteKit-optimized –
sveltekit-i18n/kitwires hooks, layouts and components: an instance per request on the server, one per tab in the browser - 📦 One install – The core and the parser come with it; nothing else to add
- ⚡ Smart loading – Translations load only for visited pages (lazy loading)
- 🎯 Route-based – Automatic translation loading based on your routes
- 🔧 Flexible – Support for custom data sources (local files, APIs, databases)
- 🧩 Extensible – Add surfaces (Svelte stores, for instance) through the extensions pipe
- 📝 TypeScript – Complete type definitions, with keys and payloads typed by a schema registered once for the whole app, generated by
@sveltekit-i18n/typegen - 🎨 Component-scoped – Create multiple translation instances for different parts of your app
Svelte 5 or newer, and one of Node 22+, Bun 1.2+ or Deno 2+. The package is
ESM-only and imports no node: module, so every runtime that runs your
SvelteKit build runs it.
npm install sveltekit-i18n
# bun add sveltekit-i18n
# deno add npm:sveltekit-i18nThat is the whole install. @sveltekit-i18n/base and
@sveltekit-i18n/parser-curly come with it: the core's whole API, the parser's
types and its build-time extractParamsFactory and cst are re-exported here
— do not install them alongside, or your app ends up with two copies of the
core and two reactive graphs.
// src/lib/translations/cs/common.json
{
"greeting": "Ahoj, {{name}}!",
"nav.home": "Domů",
"nav.about": "O nás"
}// src/lib/i18n.js
import { defineI18n } from 'sveltekit-i18n/kit';
export const config = {
initLocale: 'en',
loaders: [
{
locale: ['en', 'cs'],
namespace: 'common',
loader: async ({ locale, namespace }) => (await import(`./translations/${locale}/${namespace}.json`)).default,
},
],
};
export const { handle, load, use, get } = defineI18n(config, {
preferredLocale: (event) => event.cookies?.get('lang'),
});A loader descriptor may list several locales and namespaces; the loader is called once per pair, with the pair in its props. No parser is stated: this package fills that slot.
// src/hooks.server.js
export { handle } from '$lib/i18n';// src/routes/+layout.server.js and src/routes/+layout.js — the same line in both
export { load } from '$lib/i18n';<!-- src/routes/+layout.svelte -->
<script>
import { use } from '$lib/i18n';
let { data, children } = $props();
use(() => data);
</script>
{@render children()}<!-- src/app.html -->
<html lang="%lang%" dir="%dir%">The server negotiates the locale on every request — preferredLocale, then
Accept-Language, then initLocale, fallbackLocale and the first locale the
config serves — loads it into an instance of its own and hands its state to
the browser, which keeps one instance per tab and does not fetch again what the
server loaded. handle fills %lang% and %dir%.
<!-- src/routes/+page.svelte -->
<script>
import { get } from '$lib/i18n';
const i18n = get();
</script>
<h1>{i18n.t('common.greeting', { name: 'World' })}</h1>
<nav>
<a href="/">{i18n.t('common.nav.home')}</a>
<a href="/about">{i18n.t('common.nav.about')}</a>
</nav>The call reads the reactive translation table and locale, so the text updates
when either changes. Keep the instance, not its parts: locale, locales,
loading, initialized and translations are reactive properties, and a
destructured value is a one-time snapshot. t and l are functions and stay
reactive even when destructured. If you prefer the $t store form, add
@sveltekit-i18n/extension-stores
to config.extensions.
A client-only app (export const ssr = false) can skip the wiring and export
one instance:
// src/lib/i18n.js
import { I18n } from 'sveltekit-i18n';
export const config = {/* as in step 2 */};
export const i18n = new I18n(config);// src/routes/+layout.js
import { i18n } from '$lib/i18n';
export const ssr = false;
export const load = async ({ url }) => {
await i18n.loadTranslations('en', url.pathname);
};Important
That instance is a module-level singleton. On the server it is shared by
every request in the process, so one visitor's locale can end up in another
visitor's page. Anything that server-renders per visitor uses
sveltekit-i18n/kit above.
Everything lives on one reactive instance:
| Member | What it is |
|---|---|
t(key, ...params) |
translates for the active locale |
l(locale, key, ...params) |
translates for a locale the call names |
locale |
the active locale; assigning it is a fire-and-forget setLocale() |
locales |
the locales the config knows |
loading |
true while any activating load is in flight |
initialized |
true once a locale and a route are set and translations are present |
translations / rawTranslations |
the tables, after and before preprocessing |
loadTranslations(locale, route?, { activate? }), setLocale, setRoute |
return the promise of the matching load; { activate: false } only fills the tables |
loadNamespace(namespace, locale?) |
loads one namespace on demand, whatever the route |
loadConfig |
returns the promise of the config load |
snapshot(options?), hydrate(envelope?) |
the SSR hand-off, server half and client half |
addTranslations, invalidate(locale?, namespace?), destroy |
synchronous |
Reading a property is reactive wherever reads are tracked — a component
template, $derived, $effect. The full reference is in
the API documentation.
Load translations only for specific routes to optimize performance:
const config = {
loaders: [
{
locale: 'en',
namespace: 'home',
routes: ['/'], // Load only on homepage
loader: async () => (await import('./en/home.json')).default,
},
{
locale: 'en',
namespace: 'about',
routes: ['/about'], // Load only on about page
loader: async () => (await import('./en/about.json')).default,
},
],
};Each loader is recorded on its own, so one namespace may also be split into
route-scoped loaders: each part loads on its own route and merges into the
rest. A named capture group in a route RegExp is a route param — it reaches
the loader as params, and the loader runs again when it changes:
import { PUBLIC_API_ORIGIN } from '$env/static/public';
{
locale: 'en',
namespace: 'article',
routes: [/^\/article\/(?<id>[^/]+)/],
loader: async ({ locale, params }) => (await fetch(`${PUBLIC_API_ORIGIN}/api/articles/${params.id}/i18n/${locale}`)).json(),
}A loader runs on the server too, where fetch takes only an absolute URL
(the core hands a loader no fetch of its own), so build the URL from an
origin, as PUBLIC_API_ORIGIN does here, or back the loader with a remote
query.
A loader runs once per freshness window and route params. One whose source
caches on its own — a remote query, an SWR layer — sets cache: false and
runs on every trigger that selects it.
Use dynamic values in your translations:
{
"welcome": "Welcome, {{name}}!",
"items": "You have {{count:number;}} {{count:plural; one:item; other:items;}}."
}<script>
import { get } from '$lib/i18n';
const i18n = get();
</script>
<p>{i18n.t('welcome', { name: 'Alice' })}</p>
<p>{i18n.t('items', { count: 5 })}</p>The syntax is the Curly Message Format.
Its parser options — custom modifiers, modifier defaults, a report channel and
how payload values are read — go under config.parserOptions:
const config = {
parserOptions: {
modifierDefaults: { number: { maximumFractionDigits: 2 } },
onReport: (report) => console.warn(report.message, report),
},
loaders: [/* … */],
};Reports are silent by default; onReport is where you route them.
sveltekit-i18n/kit builds one instance per request on the server — a
module-level instance is shared between concurrent requests, which leaks one
visitor's locale into another's page — and hands its state to the browser. The
API documentation covers how it picks the locale
and what to watch for.
Wiring it by hand takes the same two halves: the server returns
snapshot({ records: true }), which carries the data and the loaders that
delivered it, and the client applies it with hydrate(), so those loaders do
not run again:
// src/routes/+layout.server.js
import { I18n } from 'sveltekit-i18n';
import { config } from '$lib/i18n';
export const load = async ({ url, locals }) => {
const i18n = new I18n(config);
await i18n.loadTranslations(locals.locale, url.pathname);
return { i18n: i18n.snapshot({ records: true }) };
};// src/routes/+layout.js, where the instance is built
i18n.hydrate(data?.i18n);Data passed to addTranslations() or config.translations only seeds the
tables: it keeps no loader from running. The full manual recipe is in
Server-Side Rendering.
An app served under SvelteKit's kit.paths.base sets the same value as
config.basePath, so loader routes keep naming the app's own paths
(/about, not /repo/about).
sveltekit-i18n/utils publishes the helpers the core uses where application
code has to match it — sanitizeLocales, toDotNotation, resolveLoaders —
and two for choosing and writing a locale: matchLocale (Accept-Language,
navigator.languages or a cookie against the configured set) and
textDirection ('ltr' or 'rtl').
A 3.2 config loads in 3.3 as it is. What to check:
- A pass always has a locale when the config serves one. When neither
what the visitor prefers,
initLocalenorfallbackLocalenames a served locale,sveltekit-i18n/kitnow takes the first locale the config serves (the loaders' locales in config order, then thetranslationskeys) instead of rendering without one. SetinitLocaleto choose the locale such a visitor gets.
The core's notes: base — Upgrading from 3.1.
A 3.0 config loads in 3.1 as it is. What to check:
- Messages follow version 3 of the Curly Message Format. A payload value is
data and is never read as syntax, so a catalogue that composed messages
through its payload, or that doubled backslashes in values, renders
differently;
parserOptions.onSuspectValueannounces every such value while you migrate.pass-limitis gone fromReport['code']. - Seeds no longer count as loaded. A client that applied the server's
snapshot()withaddTranslations()now fetches everything again after hydration — move tosveltekit-i18n/kit, or tosnapshot({ records: true })withhydrate(). - A loader's
keyis nownamespace.keystill works and logs a deprecation warning once per loader; it goes in the next major. - SvelteKit's
redirect()anderror()below 500, thrown from a loader, reject the load instead of failing soft. - Named capture groups in route
RegExps are route params, and each loader of a namespace is recorded on its own.
The whole list is in base's upgrade notes, and the format's move in parser-curly's changelog.
🌐 sveltekit-i18n.github.io – The documentation site, with a live playground
📖 Complete Documentation Index – Find everything in one place
- 🚀 Getting Started Guide – 15-minute tutorial
- 🏗️ Architecture Overview – How everything works
- 📚 API Documentation – Complete reference
- ✨ Best Practices – Production-ready patterns
- 🔧 Troubleshooting – Common issues & FAQ
Each example is a standalone SvelteKit application covering a decision that is
application-shaped — an adapter, a svelte.config.js, a route tree:
- Multi-page app – the common setup: cookie and
Accept-Language, route-scoped loading - Locale-based routing – SEO-friendly URLs (e.g.
/en/about), prerendered - Default locale without a prefix –
/aboutand/cs/about, static, translated 404 - Component-scoped translations – a component with its own lexicon
- Markdown routes –
t()inside.svx - All examples – complete list
Everything that is really three lines of configuration — message formats,
preprocess, loaders, fallbackLocale — is on the
playground instead, where a real
instance answers as you change it.
This package wires @sveltekit-i18n/parser-curly and fills the core's parser
slot itself, so a different message format means building on
@sveltekit-i18n/base directly:
import { I18n } from '@sveltekit-i18n/base';
import parser from '@sveltekit-i18n/parser-icu';
const config = {
parser: parser({ onReport: null }),
// ... rest of config
};That is the one case where installing the core directly is right — you are then
not using this package at all. The same goes for
parser-mf2
(Unicode MessageFormat 2) and
parser-i18next
(the i18next syntax). Learn more about
parsers.
config.extensions pipes the constructed instance through adapter functions,
left to right, and new I18n(config) evaluates to the last one's output. That
is how the store surface ships:
import { I18n } from 'sveltekit-i18n';
import stores from '@sveltekit-i18n/extension-stores';
export const { t, locale, loading } = new I18n({ ...config, extensions: [stores] });Full TypeScript support with complete type definitions for configuration and API:
import { I18n, type Config } from 'sveltekit-i18n';
const config: Config = {
loaders: [
// ... your loaders
],
};
export const i18n = new I18n(config);Annotating the config (const config: Config = …) widens it, which costs the
locale completion a config literal would have given setLocale and l. Pass
the literal straight to the constructor where you want that.
To have keys and payloads checked, type the instance with a schema — keys autocomplete and a wrong payload is a type error. The app registers one schema for every instance (see Generating the schema), or a config states its own:
import { I18n } from 'sveltekit-i18n';
const i18n = new I18n({
...config,
schema: {} as { 'common.greeting': { name: string } },
});
i18n.t('common.greeting', { name: 'Alice' }); // ok
i18n.t('common.greting', { name: 'Alice' }); // Error: not a key of the schema
i18n.t('common.greeting', {}); // Error: `name` is requiredOnly the schema's type is read, so the slot may hold an empty value. A single payload type for every message is stated through the type arguments instead:
import { I18n, type Config } from 'sveltekit-i18n';
type Payload = { name: string };
const config: Config<Payload> = { /* … */ };
export const i18n = new I18n<Config<Payload>, Payload>(config);That is for an app without a registered schema: Config<Payload> leaves the
schema slot any, so a registered schema types this instance instead. The
opt-out keeps the
payload type.
@sveltekit-i18n/typegen is a
Vite plugin that writes the schema from your own translations, on vite build
and while vite dev runs:
npm install -D @sveltekit-i18n/typegen// vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import { typegen } from '@sveltekit-i18n/typegen';
export default {
plugins: [
sveltekit(),
typegen({ config: 'src/lib/i18n.js', extractParams: { from: 'sveltekit-i18n' } }),
],
};It writes src/i18n-schema.d.ts (reproducible, so ignore it in Git), which
declares a global TranslationSchema and
registers it in the global SvelteKitI18n.Register interface. Every instance
whose config states no schema is then typed by it — new I18n(config) and
defineI18n(config) alike — with nothing to wire:
// src/lib/i18n.js
export const { handle, load, use, get } = defineI18n(config, {
preferredLocale: (event) => event.cookies?.get('lang'),
});The registry needs sveltekit-i18n 3.1 or newer; an older core ignores the
registration without a diagnostic. A schema the config states wins over the
registry: schema: {} as TranslationSchema is still the per-instance cast (and
what a 3.0 core or an older typegen needs), a different closed schema types an
instance with a catalogue of its own, and schema: {} opts an instance out, to
plain string keys — when the constructor infers the config's type; a config
type passed as a type argument decides instead. The registry covers the whole
program, so only the app registers — a library never does. The plugin reads the config module's
config export, so keep exporting it. Written by hand, the schema can also be
derived with the re-exported
extractParamsFactory, which reports
what each message expects of its payload.
We welcome contributions! Please read our Contributing Guide for details on:
- Development setup and workflow
- Git workflow (rebase-based, linear history)
- Commit guidelines (atomic commits)
- Pull request process
- Code standards and testing
See Releases for version history.
- @sveltekit-i18n/base – Core functionality with custom parser support
- @sveltekit-i18n/parser-curly – Curly Message Format parser (included here)
- @sveltekit-i18n/parser-icu – ICU message format parser
- @sveltekit-i18n/parser-mf2 – Unicode MessageFormat 2 parser
- @sveltekit-i18n/parser-i18next – i18next syntax parser
- @sveltekit-i18n/extension-stores – Svelte store surface for the instance
- @sveltekit-i18n/typegen – generates the
schematype from your translations
You can support the maintenance of this package through GitHub Sponsors.
MIT