@jorvel/i18n
A ~3 KB i18n primitive shaped after ICU MessageFormat: simple placeholders, plural arms, number formatting, lazy catalogs, change-listener for re-rendering on locale swap. No dependencies beyond Intl — runs on Node, the browser, and edge workers.
Quickstart
import { createI18n } from '@jorvel/i18n';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
catalogs: {
en: {
greet: 'Hello, {name}',
items: '{count, plural, =0 {No items} one {# item} other {# items}}',
},
fr: {
greet: 'Bonjour, {name}',
items: '{count, plural, =0 {Aucun élément} one {# article} other {# articles}}',
},
},
});
i18n.t('greet', { name: 'Ada' }); // 'Hello, Ada'
i18n.t('items', { count: 3 }); // '3 items'
await i18n.setLocale('fr');
i18n.t('items', { count: 0 }); // 'Aucun élément'Shared singleton across micro-frontends
createI18n makes an isolated instance. In a federated app the host and each remote bundle their own copy of @jorvel/i18n, so they would each get a different instance — and a different active locale. getI18n pins one instance to globalThis so every caller shares the same locale and catalogs, exactly like getStore / getEventBus.
import { getI18n, setI18n, createI18n } from '@jorvel/i18n';
// Host — configure ONCE before remotes load:
setI18n(createI18n({ locale: 'en', catalogs: { en: { greet: 'Hi, {name}' } } }));
// Anywhere (host or any remote) — same instance, same active locale:
const i18n = getI18n();
i18n.t('greet', { name: 'Ada' });
await i18n.setLocale('fr'); // every remote that subscribed re-rendersFirst call wins
getI18n(opts) creates the instance from opts only on the first call (defaulting to { locale: 'en' }); later calls ignore opts and return the existing instance. Configure it in the host before remotes mount, or replace it outright with setI18n(instance).Interpolation grammar
| Token | Use |
|---|---|
{name} | Substitute a value |
{count, plural, one {…} other {…}} | Plural arm via Intl.PluralRules |
{count, plural, =0 {…} other {…}} | Exact match wins over category |
{n, number} | Locale-aware grouping (1,234,567) |
{n, number, percent} | Percent style |
# | Inside a plural arm, substitutes the numeric value |
Missing keys
If a key is missing in the active locale, the runtime tries (in order): the base-language catalog (en-US → en), the fallback locale, and finally the key itself. The key fallback makes development obvious — you immediately see which strings need translation.
Lazy catalogs
Pass a loader to fetch a catalog on demand. The first call to setLocale(x) awaits the loader and caches the result. Subsequent locale swaps are synchronous.
const i18n = createI18n({
locale: 'en',
catalogs: { en: { greet: 'Hi, {name}' } },
loader: async (locale) => {
const res = await fetch(`/locales/${locale}.json`);
return await res.json();
},
});
await i18n.setLocale('ja'); // fetches /locales/ja.json
i18n.t('greet', { name: 'Ada' });setLocale is race-safe
setLocale applies the latestcall's result — if an earlier locale's loader resolves after a later one, the stale result is discarded rather than clobbering the active locale.SSR locale detection
detectLocale(acceptLanguage, supported, fallback) parses an Accept-Language header, respects q values, and prefers exact matches over base-language fallbacks. Pure function — safe on edge runtimes.
import { detectLocale, createI18n } from '@jorvel/i18n';
export async function handler(req: Request): Promise<Response> {
const accept = req.headers.get('accept-language') ?? undefined;
const locale = detectLocale(accept, ['en', 'fr-CA', 'ja'], 'en');
const i18n = createI18n({ locale, catalogs: await loadCatalogs(locale) });
// …render with i18n.t(...)
}Hydrating client from server
Serialize the active locale + catalog into the HTML, then re-create the i18n instance on the client. serializeState from @jorvel/ssr safely escapes the JSON for inline script injection.
import { serializeState } from '@jorvel/ssr';
const html = template
.replace('</head>', `
<script id="__JORVEL_I18N__" type="application/json">${serializeState({
locale: i18n.locale,
catalogs: i18n.catalogs,
})}</script>
</head>`);import { createI18n } from '@jorvel/i18n';
const raw = document.getElementById('__JORVEL_I18N__')?.textContent;
const hydrated = raw ? JSON.parse(raw) : undefined;
export const i18n = createI18n({
locale: hydrated?.locale ?? 'en',
catalogs: hydrated?.catalogs ?? {},
fallbackLocale: 'en',
});React adapter (BYO)
The package stays framework-agnostic. A tiny React adapter is one useSyncExternalStore away:
import { useSyncExternalStore } from 'react';
import { i18n } from './i18n';
export function useTranslation() {
useSyncExternalStore(i18n.subscribe, () => i18n.locale, () => i18n.locale);
return { t: i18n.t.bind(i18n), locale: i18n.locale, setLocale: i18n.setLocale.bind(i18n) };
}I18n interface
interface I18n {
locale: string;
t(key: string, values?: FormatValues): string;
setLocale(locale: string): Promise<void>;
subscribe(listener: () => void): () => void;
load(locale: string): Promise<void>; // load without switching
catalogs: Catalog;
}Multi-locale SSR
Generating the same page in N locales? Build one i18n per locale and run them in parallel — the runtime is stateless except for the catalogs object, so memory stays bounded.
const renders = await Promise.all(
['en', 'fr', 'ja'].map(async (locale) => {
const i18n = createI18n({ locale, catalogs: { [locale]: await loadCatalog(locale) } });
return { locale, html: await renderRouteToString(App, { i18n, path: '/' }) };
}),
);Subscribe + re-render
i18n.subscribe(fn) notifies you on every setLocale / load. Wrap it in a React/Vue/Svelte adapter to re-render the tree.Locale-prefixed routes
Serve /en/dashboard, /fr/dashboard with the routing helpers. extractLocale splits the prefix, localizePath adds one, stripLocale removes it.
import { extractLocale, localizePath, stripLocale } from '@jorvel/i18n';
const LOCALES = ['en', 'fr', 'ar'];
extractLocale('/fr/dashboard', LOCALES); // { locale: 'fr', rest: '/dashboard' }
localizePath('/dashboard', 'fr', { locales: LOCALES }); // '/fr/dashboard'
stripLocale('/fr/dashboard', LOCALES); // '/dashboard'Detection middleware
negotiateLocale parses Accept-Language (with a cookie override); localeMiddleware returns a JORVEL middleware decision that redirects unprefixed paths to the best locale.
import { localeMiddleware } from '@jorvel/i18n';
export default localeMiddleware({ supported: ['en', 'fr', 'ar'], default: 'en' });
// GET /dashboard with Accept-Language: fr → 307 → /fr/dashboardRTL layout
import { htmlDirAttrs, isRtlLocale } from '@jorvel/i18n';
const { lang, dir } = htmlDirAttrs(locale); // ar → { lang: 'ar', dir: 'rtl' }
// <html lang={lang} dir={dir}> — flips the whole document; use logical CSS props
// (margin-inline-start, etc.) so components mirror automatically.Full ICU MessageFormat
formatMessage handles plural, number, and now select/gender + dates:
formatMessage('{g, select, male {He} female {She} other {They}} liked this', { g: 'female' });
// → 'She liked this'
formatMessage('{n, plural, one {# item} other {# items}}', { n: 3 }); // '3 items'
formatMessage('Posted {when, date, medium}', { when: new Date() }, 'en'); // 'Posted Jan 15, 2026'