[![npm version](https://badge.fury.io/js/sveltekit-i18n.svg)](https://badge.fury.io/js/sveltekit-i18n) [![Tests](https://github.com/sveltekit-i18n/lib/actions/workflows/tests.yml/badge.svg)](https://github.com/sveltekit-i18n/lib/actions/workflows/tests.yml)

# sveltekit-i18n

A lightweight, powerful internationalization (i18n) library designed specifically for [SvelteKit](https://github.com/sveltejs/kit). This package combines [@sveltekit-i18n/base](https://github.com/sveltekit-i18n/base) with [@sveltekit-i18n/parser-curly](https://github.com/sveltekit-i18n/parsers/tree/master/parser-curly) to provide the quickest way to add multilingual support to your SvelteKit applications.

## Why sveltekit-i18n?

- 🚀 **SvelteKit-optimized** – `sveltekit-i18n/kit` wires 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`](https://github.com/sveltekit-i18n/typegen)
- 🎨 **Component-scoped** – Create multiple translation instances for different parts of your app

## Requirements

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.

## Installation

```bash
npm install sveltekit-i18n
# bun add sveltekit-i18n
# deno add npm:sveltekit-i18n
```

That 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.

## Quick Start

### 1. Create your translation files

```jsonc
// src/lib/translations/en/common.json
{
  "greeting": "Hello, {{name}}!",
  "nav.home": "Home",
  "nav.about": "About"
}
```

```jsonc
// src/lib/translations/cs/common.json
{
  "greeting": "Ahoj, {{name}}!",
  "nav.home": "Domů",
  "nav.about": "O nás"
}
```

### 2. Define the config and wire it

```javascript
// src/lib/i18n.js
import { defineI18n } from 'sveltekit-i18n/kit';

export const config = {
  fallbackLocale: '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.

### 3. Hook it into SvelteKit

```javascript
// src/hooks.server.js
export { handle } from '$lib/i18n';
```

```javascript
// src/routes/+layout.server.js and src/routes/+layout.js — the same line in both
export { load } from '$lib/i18n';
```

```svelte
<!-- src/routes/+layout.svelte -->
<script>
  import { use } from '$lib/i18n';

  let { data, children } = $props();

  use(() => data);
</script>

{@render children()}
```

```html
<!-- 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%`.

### 4. Use translations in your components

```svelte
<!-- 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`](https://github.com/sveltekit-i18n/extensions/tree/master/extension-stores)
to `config.extensions`.

### Without a server

A client-only app (`export const ssr = false`) can skip the wiring and export
one instance:

```javascript
// src/lib/i18n.js
import { I18n } from 'sveltekit-i18n';

export const config = {/* as in step 2 */};

export const i18n = new I18n(config);
```

```javascript
// 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.

## The instance

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](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/README.md).

## Key Features

### Route-based Loading

Load translations only for specific routes to optimize performance:

```javascript
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:

```javascript
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.

### Placeholders and Modifiers

Use dynamic values in your translations:

```json
{
  "welcome": "Welcome, {{name}}!",
  "items": "You have {{count:number;}} {{count:plural; one:item; other:items;}}."
}
```

```svelte
<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](https://curlymessage.dev).
Its parser options — custom modifiers, modifier defaults, a report channel and
how payload values are read — go under `config.parserOptions`:

```javascript
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.

### Server-side rendering

`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](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/README.md#sveltekit) 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:

```javascript
// 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 }) };
};
```

```javascript
// 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](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/README.md#server-side-rendering).

### Base path

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`).

### Utilities

`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'`).

## Upgrading from 3.2

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, `initLocale` nor `fallbackLocale` names a served
  locale, `sveltekit-i18n/kit` now takes the first locale the config serves
  (the loaders' locales in config order, then the `translations` keys) instead
  of rendering without one. Set `initLocale` to choose the locale such a
  visitor gets.

The core's notes:
[base — Upgrading from 3.1](https://github.com/sveltekit-i18n/base/blob/master/docs/README.md#upgrading-from-31).

## Upgrading from 3.0

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.onSuspectValue` announces every such value while
  you migrate. `pass-limit` is gone from `Report['code']`.
- **Seeds no longer count as loaded.** A client that applied the server's
  `snapshot()` with `addTranslations()` now fetches everything again after
  hydration — move to `sveltekit-i18n/kit`, or to
  `snapshot({ records: true })` with `hydrate()`.
- **A loader's `key` is now `namespace`.** `key` still works and logs a
  deprecation warning once per loader; it goes in the next major.
- **SvelteKit's `redirect()` and `error()` below 500**, thrown from a loader,
  reject the load instead of failing soft.
- **Named capture groups in route `RegExp`s are route params**, and each loader
  of a namespace is recorded on its own.

The whole list is in
[base's upgrade notes](https://github.com/sveltekit-i18n/base/blob/master/docs/README.md#upgrading-from-30),
and the format's move in
[parser-curly's changelog](https://github.com/sveltekit-i18n/parsers/blob/master/parser-curly/CHANGELOG.md#310).

## Documentation

**🌐 [sveltekit-i18n.github.io](https://sveltekit-i18n.github.io)** – The documentation site, with a live playground

**📖 [Complete Documentation Index](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/INDEX.md)** – Find everything in one place

### Quick Links

- 🚀 [Getting Started Guide](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/GETTING_STARTED.md) – 15-minute tutorial
- 🏗️ [Architecture Overview](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/ARCHITECTURE.md) – How everything works
- 📚 [API Documentation](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/README.md) – Complete reference
- ✨ [Best Practices](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/BEST_PRACTICES.md) – Production-ready patterns
- 🔧 [Troubleshooting](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/TROUBLESHOOTING.md) – Common issues & FAQ

## Examples

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](https://github.com/sveltekit-i18n/lib/tree/3.3.0/examples/multi-page) – the common setup: cookie and `Accept-Language`, route-scoped loading
- [Locale-based routing](https://github.com/sveltekit-i18n/lib/tree/3.3.0/examples/locale-router) – SEO-friendly URLs (e.g. `/en/about`), prerendered
- [Default locale without a prefix](https://github.com/sveltekit-i18n/lib/tree/3.3.0/examples/locale-router-advanced) – `/about` and `/cs/about`, static, translated 404
- [Component-scoped translations](https://github.com/sveltekit-i18n/lib/tree/3.3.0/examples/component-scoped-ssr) – a component with its own lexicon
- [Markdown routes](https://github.com/sveltekit-i18n/lib/tree/3.3.0/examples/mdsvex) – `t()` inside `.svx`
- [All examples](https://github.com/sveltekit-i18n/lib/tree/3.3.0/examples) – complete list

Everything that is really three lines of configuration — message formats,
`preprocess`, `loaders`, `fallbackLocale` — is on the
[playground](https://sveltekit-i18n.github.io/playground) instead, where a real
instance answers as you change it.

## Advanced Usage

### Need a different parser?

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](https://github.com/sveltekit-i18n/base) directly:

```javascript
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`](https://github.com/sveltekit-i18n/parsers/tree/master/parser-mf2)
(Unicode MessageFormat 2) and
[`parser-i18next`](https://github.com/sveltekit-i18n/parsers/tree/master/parser-i18next)
(the i18next syntax). Learn more about
[parsers](https://github.com/sveltekit-i18n/parsers).

### Extensions

`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:

```javascript
import { I18n } from 'sveltekit-i18n';
import stores from '@sveltekit-i18n/extension-stores';

export const { t, locale, loading } = new I18n({ ...config, extensions: [stores] });
```

## TypeScript Support

Full TypeScript support with complete type definitions for configuration and API:

```typescript
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](#generating-the-schema)), or a
config states its own:

```typescript
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 required
```

Only 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:

```typescript
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](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/README.md#one-payload-type-for-every-message) keeps the
payload type.

### Generating the schema

[`@sveltekit-i18n/typegen`](https://github.com/sveltekit-i18n/typegen) is a
Vite plugin that writes the schema from your own translations, on `vite build`
and while `vite dev` runs:

```bash
npm install -D @sveltekit-i18n/typegen
```

```javascript
// 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:

```javascript
// 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`](https://github.com/sveltekit-i18n/lib/blob/3.3.0/docs/README.md#extractparamsfactory), which reports
what each message expects of its payload.

## Contributing

We welcome contributions! Please read our [Contributing Guide](https://github.com/sveltekit-i18n/lib/blob/3.3.0/CONTRIBUTING.md) for details on:

- Development setup and workflow
- Git workflow (rebase-based, linear history)
- Commit guidelines (atomic commits)
- Pull request process
- Code standards and testing

## Changelog

See [Releases](https://github.com/sveltekit-i18n/lib/releases) for version history.

## Related Packages

- [@sveltekit-i18n/base](https://github.com/sveltekit-i18n/base) – Core functionality with custom parser support
- [@sveltekit-i18n/parser-curly](https://github.com/sveltekit-i18n/parsers/tree/master/parser-curly) – Curly Message Format parser (included here)
- [@sveltekit-i18n/parser-icu](https://github.com/sveltekit-i18n/parsers/tree/master/parser-icu) – ICU message format parser
- [@sveltekit-i18n/parser-mf2](https://github.com/sveltekit-i18n/parsers/tree/master/parser-mf2) – Unicode MessageFormat 2 parser
- [@sveltekit-i18n/parser-i18next](https://github.com/sveltekit-i18n/parsers/tree/master/parser-i18next) – i18next syntax parser
- [@sveltekit-i18n/extension-stores](https://github.com/sveltekit-i18n/extensions/tree/master/extension-stores) – Svelte store surface for the instance
- [@sveltekit-i18n/typegen](https://github.com/sveltekit-i18n/typegen) – generates the `schema` type from your translations

## Sponsor

You can support the maintenance of this package through
[GitHub Sponsors](https://github.com/sponsors/sveltekit-i18n).

## License

MIT
