Skip to content

Repository files navigation

npm version Tests

sveltekit-i18n

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.

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
  • 🎨 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

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

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

2. Define the config and wire it

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

3. Hook it into SvelteKit

// 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%.

4. Use translations in your components

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

Without a server

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.

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.

Key Features

Route-based Loading

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.

Placeholders and Modifiers

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.

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

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.

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

Documentation

🌐 sveltekit-i18n.github.io – The documentation site, with a live playground

📖 Complete Documentation Index – Find everything in one place

Quick Links

Examples

Each example is a standalone SvelteKit application covering a decision that is application-shaped — an adapter, a svelte.config.js, a route tree:

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.

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

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:

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:

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

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.

Generating the schema

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

Contributing

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

Changelog

See Releases for version history.

Related Packages

Sponsor

You can support the maintenance of this package through GitHub Sponsors.

License

MIT

Releases

Sponsor this project

Used by

Contributors

Languages