GitHub

@nomideusz/svelte-i18n

npm license

Minimal runtime-independent i18n for Svelte 5 — a runes-based locale store, flat JSON messages, {variable} interpolation, and a tiny <LocaleSwitcher /> component. No build-time code generation, no ICU message format, no global side effects.

Live demo → svelte-i18n-five.vercel.app

Stable since 1.0. The API below is a promise: from here, anything that breaks a consumer costs a major version.

Install

pnpm add @nomideusz/svelte-i18n

Requires Svelte 5 (^5.0.0). Zero runtime dependencies.

Quick start

Create one i18n instance per app, point it at a loader function, import it anywhere:

// src/lib/i18n.ts
import { createI18n } from '@nomideusz/svelte-i18n';

import en from './messages/en.json';
import pl from './messages/pl.json';
import de from './messages/de.json';

const messages = { en, pl, de };

export const i18n = createI18n({
  defaultLocale: 'en',
  supportedLocales: ['en', 'pl', 'de'],
  // Synchronous: messages are there on the first render, which is what makes
  // per-request SSR locales safe. Async loaders work too — see below.
  loader: (locale) => messages[locale] ?? messages.en,
});
// src/lib/messages/en.json
{
  "nav.home": "Home",
  "nav.about": "About",
  "greeting": "Hello {name}, you have {count} messages."
}
<script lang="ts">
  import { i18n } from '#lib/i18n';
</script>

<h1>{i18n.t('greeting', { name: 'Jan', count: 3 })}</h1>
<a href="/">{i18n.t('nav.home')}</a>

i18n.locale, i18n.isLoading, and i18n.supportedLocales are Svelte 5 $state-backed — read them directly in components and they'll update reactively.

Why flat keys?

Keys are flat strings like "nav.home"the dots are just part of the string, not a nested path. That keeps the hot path tiny (single Map lookup, no recursion) and means JSON files stay linter- and translation-tool-friendly:

{
  "nav.home": "Home",
  "nav.about": "About",
  "billing.invoice.number": "Invoice #{number}"
}

If a key is missing, t() returns the key itself — easy to spot untranslated strings at a glance.

Switching locales

<script lang="ts">
  import { i18n } from '#lib/i18n';
</script>

<button onclick={() => i18n.setLocale('pl')}>Polski</button>
<button onclick={() => i18n.setLocale('de')}>Deutsch</button>

{#if i18n.isLoading}
  <span>Loading…</span>
{:else}
  <p>{i18n.t('current')} {i18n.locale}</p>
{/if}

setLocale() returns a promise that resolves once the new messages are loaded. Each locale is loaded once and cached — switching back to a previously-loaded locale is instant.

Attempting to switch to an unsupported locale logs a warning and does nothing.

<LocaleSwitcher />

A minimal drop-in <select>:

<script lang="ts">
  import { LocaleSwitcher } from '@nomideusz/svelte-i18n';
  import { i18n } from '#lib/i18n';
</script>

<LocaleSwitcher
  {i18n}
  labels={{ en: 'English', pl: 'Polski', de: 'Deutsch' }}
/>

Without labels, each locale renders as its uppercase code (EN, PL, DE). The disabled state is wired to i18n.isLoading so users can't spam-switch while messages are still loading. Every switch writes the choice to the locale cookie — the one createLocaleHandle reads — so it survives a reload.

Set label to translate the control's accessible name; it defaults to English because something has to be there, and in an i18n library that string should not be the one you cannot change:

<LocaleSwitcher {i18n} label={i18n.t('a11y.language')} />

With URL-locale routing, pass the routing config so a switch also navigates (/krakow/en/krakow); unprefixed mirrors the handle's option for areas without a prefix (those reload in place):

<LocaleSwitcher {i18n} {routing} unprefixed={['/admin']} />

Style via CSS variables on an ancestor — --asini-font-sans, --asini-border, --asini-border-strong, --asini-surface, --asini-text, --asini-accent, --asini-radius-sm — or pass a class prop and override styles directly:

<LocaleSwitcher {i18n} class="my-switcher" />

Interpolation

{variable} placeholders get substituted with values from the second argument to t():

i18n.t('greeting', { name: 'Jan', count: 3 });
// → "Hello Jan, you have 3 messages."

Rules:

If you need the interpolation logic standalone:

import { interpolate } from '@nomideusz/svelte-i18n';

interpolate('Hello {name}', { name: 'Jan' }); // → 'Hello Jan'

Plurals

Pass a numeric count and t() picks the CLDR plural form for the active locale — no ICU parser, no dependency; Intl.PluralRules does the selecting.

// pl.json — Polish needs three forms
{
  "school_one": "{count} szkoła",
  "school_few": "{count} szkoły",
  "school_many": "{count} szkół",
  "school_other": "{count} szkoły"
}
i18n.t('school', { count: 1 });  // → "1 szkoła"
i18n.t('school', { count: 3 });  // → "3 szkoły"
i18n.t('school', { count: 12 }); // → "12 szkół"

Rules:

Sync vs. async loaders

The loader can return a Messages object directly or a promise. Sync loaders are applied immediately during construction — no microtask, no isLoading flash — so SSR-rendered first paint includes the default locale's messages. Async loaders flip isLoading to true while they resolve. Both { default: {...} } (what dynamic JSON imports produce) and plain objects are accepted:

// Bundled into the app (no code-splitting)
import en from './messages/en.json';
import pl from './messages/pl.json';

createI18n({
  defaultLocale: 'en',
  supportedLocales: ['en', 'pl'],
  loader: (locale) => (locale === 'pl' ? pl : en),
});

// Split into per-locale chunks. Note the SSR caveat below: under per-request
// server rendering, prefer the synchronous form above.

createI18n({
  defaultLocale: 'en',
  supportedLocales: ['en', 'pl', 'de', 'uk'],
  loader: (locale) => import(`./messages/${locale}.json`),
});

// Loaded from a remote source
createI18n({
  defaultLocale: 'en',
  supportedLocales: ['en', 'pl'],
  loader: async (locale) => {
    const res = await fetch(`/messages/${locale}.json`);
    return res.json();
  },
});

Locale-aware formatting

formatDateTime, formatMoney, and intlLocale are pure, store-free functions — no Svelte, no reactive state — so the same call works in components, load functions, server hooks, and email templates, where the reactive locale in createI18n would race under concurrent per-request (or per-recipient) rendering. Pages and emails share one regional policy.

import { formatDateTime, formatMoney, intlLocale } from '@nomideusz/svelte-i18n';

const d = new Date('2026-06-15T16:00:00Z');

formatDateTime(d, 'en', 'Europe/Warsaw'); // → "Monday, 15 June 2026 at 18:00"
formatDateTime(d, 'en-US', 'UTC');        // → "Monday, June 15, 2026 at 04:00 PM"

formatMoney(9000, 'PLN', 'pl'); // → "90,00 zł"
formatMoney(9000, 'JPY', 'en'); // → "JP¥9,000" — not ¥90

intlLocale('en');    // → "en-GB"
intlLocale('en-US'); // → "en-US" (a tag with a region is trusted as-is)

URL-locale routing (SSR)

For SEO you usually want each locale on its own crawlable URL — /about for the default locale, /en/about and /uk/about for the rest — with hreflang tags tying them together. The routing helpers are pure functions (no Svelte, no global state), so they run identically in hooks, load, and the browser.

Strategy: path prefix with a bare default locale. The default locale is never a valid prefix, so /pl/about is treated as an ordinary path, not a duplicate of /about.

// src/lib/i18n-routing.ts
import type { LocaleRoutingConfig } from '@nomideusz/svelte-i18n';

export const routing: LocaleRoutingConfig = {
  defaultLocale: 'pl',
  supportedLocales: ['pl', 'en', 'uk'],
};

URL prefix aliases

When the recognizable URL segment differs from the ISO language code, map it with prefixes. The classic case is Ukrainian: users recognize /ua/, but the language code (and therefore hreflang and <html lang>) must stay uk.

export const routing: LocaleRoutingConfig = {
  defaultLocale: 'pl',
  supportedLocales: ['pl', 'en', 'uk'],
  prefixes: { uk: 'ua' }, // URL: /ua/krakow — hreflang & lang: "uk"
};

extractLocale('/ua/krakow', routing){ locale: 'uk', pathname: '/krakow' }, localizeHref('/krakow', 'uk', routing)/ua/krakow, and alternates(...) emits hreflang="uk" pointing at the /ua/ URL. Locales without a prefixes entry use their own code. negotiateLocale is unaffected — Accept-Language always carries language codes (uk), not URL segments.

reroute — serve prefixed URLs from the existing route tree

// src/hooks.ts
import { createReroute } from '@nomideusz/svelte-i18n';
import { routing } from '#lib/i18n-routing';

export const reroute = createReroute(routing);
// /en/krakow → resolved by the /[city] route; no duplicate route files

Resolve the per-request locale on the server

// src/hooks.server.ts
import { createLocaleHandle } from '@nomideusz/svelte-i18n';
import { routing } from '#lib/i18n-routing';

// Optional: areas served without a locale prefix (admin, auth) — there the
// locale is a preference (cookie, else Accept-Language) and never written back.
export const handle = createLocaleHandle(routing, { unprefixed: ['/admin'] });

Per request the handle:

Cookie-less visitors on the default locale get no Set-Cookie, so their responses stay CDN-cacheable. Compose it with sequence() from @sveltejs/kit/hooks and declare locale: string in App.Locals.

The lower-level resolveLocale({ pathname, acceptLanguage }, routing) is exported too: URL prefix wins; Accept-Language is only consulted at the bare root, so deep un-prefixed paths stay on the default locale and remain stable for crawlers.

Localize links and emit hreflang

<script lang="ts">
  import { extractLocale, localizeHref, alternates } from '@nomideusz/svelte-i18n';
  import { routing } from '#lib/i18n-routing';
  import { page } from '$app/state';

  // The canonical, locale-stripped path for the current page:
  const path = $derived(extractLocale(page.url.pathname, routing).pathname);
  const links = $derived(alternates(path, routing, 'https://example.com'));
</script>

<svelte:head>
  {#each links as { hreflang, href }}
    <link rel="alternate" {hreflang} {href} />
  {/each}
</svelte:head>

<a href={localizeHref('/krakow', page.data.locale, routing)}>Kraków</a>

SSR tip: pair this with a synchronous message loader and set i18n.setLocale(locale) from server data at the top of your root layout. Because SvelteKit's render pass is synchronous and non-interleaved, the singleton store resolves to the right locale per request without AsyncLocalStorage or context plumbing.

When the locale is a preference, not a URL

Staff tools, admin panels and noindex pages have nothing to gain from crawlable per-locale URLs. There createLocaleHandle is the wrong shape — it exists to make a URL prefix authoritative. Read the cookie instead; it is the same one <LocaleSwitcher /> writes:

// src/hooks.server.ts
import { LOCALE_COOKIE } from '@nomideusz/svelte-i18n';
import { i18n } from '#lib/i18n';

export const handle: Handle = async ({ event, resolve }) => {
  const cookie = event.cookies.get(LOCALE_COOKIE);
  event.locals.locale = i18n.supportedLocales.includes(cookie ?? '') ? cookie! : 'pl';

  return resolve(event, {
    transformPageChunk: ({ html }) => html.replace('%lang%', event.locals.locale),
  });
};
// src/routes/+layout.server.ts
export const load = ({ locals }) => ({ locale: locals.locale });
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { untrack } from 'svelte';
  import { i18n } from '#lib/i18n';
  let { data, children } = $props();

  // Synchronous, so SSR's first paint is already in the right language. Read
  // once on purpose: after hydration the switcher owns the locale, and
  // re-applying the server's value would undo the visitor's choice.
  untrack(() => i18n.setLocale(data.locale));
</script>

That is the whole recipe — no routing config, no prefixes, no redirect.

API reference

Routing helpers

interface LocaleRoutingConfig {
  defaultLocale: string;
  supportedLocales: string[];
  /** Optional locale code → URL segment overrides, e.g. { uk: 'ua' }. */
  prefixes?: Record<string, string>;
}
Function Returns Description
extractLocale(pathname, cfg) { locale, pathname } Split a URL into its locale and delocalized path. Default locale is never a prefix.
localizeHref(path, locale, cfg) string Add the locale prefix (default locale → unchanged). Preserves query/hash; ignores external/mailto:/#; never double-prefixes the same locale — delocalize with extractLocale first when switching.
alternates(path, cfg, origin) { hreflang, href }[] hreflang link set for every locale plus x-default. Pass the delocalized path.
negotiateLocale(acceptLanguage, cfg) string Best supported locale from Accept-Language (quality-weighted, primary-subtag match). Falls back to default.
resolveLocale({ pathname, acceptLanguage }, cfg) string URL prefix wins; negotiate only at the bare root; deep un-prefixed paths stay default.
createReroute(cfg) ({ url }) => string SvelteKit reroute hook — maps prefixed URLs onto the un-prefixed route tree.
createLocaleHandle(cfg, { unprefixed? }) SvelteKit Handle Server hook: bare-root redirect, locals.locale, cookie sync, %lang%.
LOCALE_COOKIE 'locale' The preference cookie the handle and <LocaleSwitcher /> share.

createI18n(config)

interface I18nConfig {
  defaultLocale: string;
  supportedLocales: string[];
  loader: (locale: string) => Promise<Messages> | Messages;
}

type Messages = Record<string, string>;

Returns an I18nInstance:

Member Type Description
locale string Current locale (reactive)
isLoading boolean True while loading a new locale (reactive)
supportedLocales string[] The list passed to config
t(key, params?) (string, obj?) => string Translate, interpolate {vars}, and select a CLDR plural form when params.count is a number. Missing keys return the key itself.
setLocale(locale) (string) => Promise<void> Switch locale. Warns and no-ops if unsupported.

interpolate(template, params?)

interpolate(template: string, params?: Record<string, string | number>): string

Substitutes {var} placeholders in template. Missing params left as-is.

Formatting helpers

Pure and store-free — importable anywhere, server included.

Function Returns Description
intlLocale(locale) string Regional default for bare codes (en → en-GB, pl → pl-PL, uk → uk-UA); region-carrying tags and unknown codes pass through.
formatDateTime(date, locale, timeZone?) string Weekday + full date + time in the locale's conventions (24-hour for en-GB). Optional IANA time zone.
formatMoney(minorUnits, currency, locale) string Integer minor units → localized currency string; divisor derived from the currency, not hardcoded to 100.

<LocaleSwitcher />

Prop Type Description
i18n I18nInstance required
labels Record<string, string> Optional locale code → display name map
class string Optional CSS class on the <select>
label string Accessible name for the control (default 'Select language')
routing LocaleRoutingConfig Also navigate to the same page under the new locale's prefix
unprefixed string[] Mirrors createLocaleHandle's option — those areas reload in place

Every switch writes the locale cookie (exported as LOCALE_COOKIE), with or without routing.

Development

pnpm install
pnpm dev             # SvelteKit dev server (demo)
pnpm check           # Typecheck
pnpm test            # Vitest
pnpm run package     # Build the library

License

MIT