Setup
import { createI18n } from '@vielzeug/lingua';
const i18n = createI18n({
locale: 'en',
fallback: 'en',
catalogs: {
en: {
greeting: 'Hello, {name}!',
inbox: {
zero: 'No messages',
one: 'One message',
other: '{count} messages',
},
},
de: () => import('./locales/de.json').then((m) => m.default),
},
});All locale strings must be valid BCP 47 tags. createI18n, setLocale, and register throw [lingua/E004] for unrecognised tags.
Locale Lifecycle
await i18n.preload('de');
await i18n.setLocale('de');
i18n.register('fr', () => import('./locales/fr.json').then((m) => m.default));
const locales = i18n.getSupportedLocales();preload(locale)loads the catalog without switching the active locale. Use it to warm up a locale before the user requests it.setLocale(locale)loads if needed, then atomically switches and bumps the version.register(locale, source)replaces the full catalog for a locale at runtime. Existing subscribers are notified.
Locale lookup expands subtags automatically — en-US checks en-US then en before moving to explicit fallbacks.
Translation
i18n.t('greeting', { name: 'Alice' });
i18n.tp('inbox', 3);
i18n.tp('position', 2, { ordinal: true });
i18n.tp('position', 1, { vars: { name: 'Alice' }, ordinal: true });t() resolves leaf keys. tp() resolves plural branch keys (.zero, then CLDR category, then .other). count is injected automatically — do not include it in vars.
Key Inspection
Use has(key) to check whether a key exists in the active fallback chain. It returns true for leaf keys, branch keys, and pipe-plural base keys.
// catalog: { inbox: 'One message|{count} messages' } (expands to inbox.one, inbox.other)
i18n.has('inbox'); // true — branch exists after pipe-plural expansion
i18n.has('inbox.one'); // true — explicit sub-key
i18n.has('missing'); // falsehas() walks the full fallback chain, so it returns true if any fallback locale provides the key.
Scoped Helpers
scope(prefix) returns a { fmt, t, tp, has } helper bound to a key prefix. Use it inside a component or module to avoid repeating the same key segment.
const nav = i18n.scope('nav');
nav.t('home'); // resolves 'nav.home'
nav.t('menu.settings'); // resolves 'nav.menu.settings'
nav.has('logout'); // checks 'nav.logout'
nav.fmt.number(1234); // same as i18n.fmt.number(1234)scope() returns a new object on each call — do not compare references.
Formatting
Import createFormatter from the separate @vielzeug/lingua/format entry point:
import { createFormatter } from '@vielzeug/lingua/format';
// Pass a getter so the formatter follows locale changes
const fmt = createFormatter(() => i18n.locale);
fmt.number(1234567.89);
fmt.currency(19.99, 'EUR');
fmt.date(new Date(), { dateStyle: 'medium' });
fmt.relative(-3, 'day');
fmt.list(['a', 'b', 'c']);Alternatively, access i18n.fmt which is a formatter pre-wired to the instance locale:
const price = i18n.fmt.currency(49.95, 'USD');Namespace-based Lazy Loading
extend(ns, factory, locale?) registers a namespace factory and immediately loads it for locale (defaults to the active locale). Namespaces let you add per-route or per-feature keys without replacing the full catalog.
// Load when entering the settings route
async function onEnterSettings() {
await i18n.extend('settings', (locale) => import(`./locales/${locale}/settings.json`).then((m) => m.default));
// Keys from settings.json are now merged into the active locale catalog
}
// Pre-load for a specific locale
await i18n.extend('settings', (locale) => import(`./locales/${locale}/settings.json`).then((m) => m.default), 'de');Key characteristics:
- Defaults to the active locale when no
localeargument is provided. - Concurrent calls for the same
ns + localepair are deduplicated — the factory runs at most once per locale. - Subsequent calls after a successful load are no-ops.
Missing Handling
Pass onMissingKey and/or onMissingVar to createI18n to handle missing keys and unresolved interpolation variables.
const strictI18n = createI18n({
onMissingKey(key, locale) {
return `[missing:${key}]`;
},
onMissingVar(varName, key, locale) {
return `<missing:${varName}>`;
},
});Without onMissingKey, missing keys return the key string. Without onMissingVar, missing variables keep their {placeholder} text.
Validating Catalogs
Use validateCatalog() during development or CI to detect plural branches that are missing CLDR forms for a target locale. Import it from the dedicated @vielzeug/lingua/validate entry — never from the main entry or it will end up in your production bundle.
import { validateCatalog } from '@vielzeug/lingua/validate';
import ar from './locales/ar.json';
const warnings = validateCatalog(ar, 'ar');
// Arabic requires: zero, one, two, few, many, other
// warnings = [{ key: 'inbox', locale: 'ar', form: 'zero' }, ...]
if (warnings.length > 0) {
throw new Error(`Missing plural forms:\n${JSON.stringify(warnings, null, 2)}`);
}The function compares present plural forms against the full CLDR set for the given locale using Intl.PluralRules. It also warns when a other, two, few, or many form template does not contain {count} — these warnings carry form: '<form>:missing-count'. The zero and one forms are exempt.
Forking
fork(overrides?) creates a child instance that inherits the parent's current catalog snapshot and namespace registry, but has its own locale, fallback chain, and subscribers. Use it to isolate per-request locale state in SSR, or to create a test instance without polluting the shared one.
// SSR: share catalog setup; one fork per request
const reqI18n = i18n.fork({ locale: req.locale });
await reqI18n.setLocale(req.locale);
const html = `<h1>${reqI18n.t('title')}</h1>`;
// Tests: custom missing-key handler without polluting the shared instance
const testI18n = i18n.fork({ onMissingKey: (k) => `MISSING:${k}` });Key characteristics:
- Catalog mutations on the fork do not affect the parent, and vice versa.
- Namespace dedup markers are copied at fork time. Calling
extend()on a fork for an already-loadedns + localepair is a no-op. - Forks do not inherit subscribers.
SSR Hydration
Use serializeI18n() on the server and hydrateI18n() on the client to avoid re-fetching catalogs:
import { createI18n, serializeI18n, hydrateI18n } from '@vielzeug/lingua';
// Server (Node.js / Deno)
const i18n = createI18n({ catalogs: { de: deMessages, en: enMessages }, locale: 'de' });
const state = serializeI18n(i18n);
// Embed state in the HTML response:
// <script>window.__I18N__ = ${JSON.stringify(state)}</script>
// Client
const i18n = createI18n({ catalogs: { en: enMessages, de: () => import('./de.json').then((m) => m.default) } });
hydrateI18n(i18n, window.__I18N__);
// Catalogs from state are immediately available; no network request needed.hydrateI18n() replaces all catalogs and switches the active locale, notifying subscribers once.
Warning: serializeI18n() silently omits locales that were registered as async loaders but not yet preloaded. Use isLoaded() to guard:
const locales = i18n.getSupportedLocales();
await Promise.all(locales.filter((l) => !i18n.isLoaded(l)).map((l) => i18n.preload(l)));
const state = serializeI18n(i18n); // all locales guaranteed to be presentSubscriptions
subscribe(callback, options?) fires on every locale or catalog change. It returns an Unsubscribe function.
const unsubscribe = i18n.subscribe(
({ locale }) => {
document.documentElement.lang = locale;
},
{ immediate: true },
);
// Later
unsubscribe();Pass { signal } to tie the subscription lifetime to an AbortController — useful in component lifecycle hooks:
// React useEffect
useEffect(() => {
const controller = new AbortController();
i18n.subscribe(
({ locale }) => {
document.documentElement.lang = locale;
},
{ immediate: true, signal: controller.signal },
);
return () => controller.abort();
}, []);
// Svelte onDestroy
const controller = new AbortController();
i18n.subscribe(
({ locale }) => {
snapshot = locale;
},
{ signal: controller.signal },
);
onDestroy(() => controller.abort());If the signal is already aborted when subscribe() is called, no subscription is created and the callback is never invoked.
Framework Integration
i18n exposes subscribe / getSnapshot semantics and wires directly into any framework reactive system.
import { useSyncExternalStore } from 'react';
import { createI18n } from '@vielzeug/lingua';
const i18n = createI18n({
locale: 'en',
catalogs: { en: { greeting: 'Hello, {name}!' }, de: () => import('./de.json').then((m) => m.default) },
});
function useI18nSnapshot() {
return useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot);
}
function Greeting({ name }: { name: string }) {
useI18nSnapshot(); // re-renders when locale changes
return <p>{i18n.t('greeting', { name })}</p>;
}import { shallowRef, onScopeDispose } from 'vue';
import { createI18n } from '@vielzeug/lingua';
const i18n = createI18n({
locale: 'en',
catalogs: { en: { greeting: 'Hello, {name}!' } },
});
function useI18n() {
const snapshot = shallowRef(i18n.getSnapshot());
const stop = i18n.subscribe(
(s) => {
snapshot.value = s;
},
{ immediate: true },
);
onScopeDispose(stop);
return snapshot;
}<script lang="ts">
import { onDestroy } from 'svelte';
import { createI18n } from '@vielzeug/lingua';
const i18n = createI18n({
locale: 'en',
catalogs: { en: { greeting: 'Hello, {name}!' } },
});
let snapshot = i18n.getSnapshot();
const stop = i18n.subscribe(
(s) => { snapshot = s; },
{ immediate: true },
);
onDestroy(() => stop());
</script>
<p>{i18n.t('greeting', { name: 'Alice' })}</p>Working with Other Vielzeug Libraries
With Wayfinder
Use Wayfinder path params or query params as the source of truth for locale selection.
import { createI18n } from '@vielzeug/lingua';
import { createBrowserHistory, createRouter } from '@vielzeug/wayfinder';
const i18n = createI18n({ locale: 'en', catalogs: { en: { title: 'Home' }, de: { title: 'Startseite' } } });
const router = createRouter({ history: createBrowserHistory(), routes: [{ path: '/:locale/home', id: 'home' }] });
router.subscribe(() => {
const locale = router.current.params.locale;
if (locale) i18n.setLocale(locale);
});Best Practices
- Call
preload(locale)beforesetLocale(locale)to avoid a render with missing translations. - Use lazy catalog functions (
() => import('./locales/de.json')) for locales not needed at startup. - Keep translation keys flat or one level deep — deeply nested keys are harder to refactor.
- Set
fallbackto a locale with 100% coverage so missing keys degrade gracefully. - Use
extend(ns, factory, locale?)for per-route or per-feature key sets to add keys without replacing the full catalog. - Use
isLoaded(locale)beforeserializeI18n()in SSR to avoid silently omitting async-loader locales. - Use
isRegistered(locale)to check if a locale is configured; useisLoaded(locale)to check if it is ready. - Call
dispose()on route-level or request-scopedfork()instances when they are no longer needed. - Use
{ signal }insubscribe()for lifecycle-safe subscriptions; use the returnedUnsubscribeotherwise. - Use
onMissingKeyandonMissingVarin development to surface authoring errors early; omit them in production. - Import
validateCatalogfrom@vielzeug/lingua/validatein CI only — never in application code. - Share one
i18ninstance per app entry point; avoid creating separate instances per component.