JORVEL

@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

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

ts
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-renders

First 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

TokenUse
{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-USen), 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.

ts
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

Concurrent loads of the same locale are deduped onto a single in-flight promise, and 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.

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

server
ts
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>`);
client
ts
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:

tsx
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

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

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

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

apps/shell/src/middleware.ts
ts
import { localeMiddleware } from '@jorvel/i18n';

export default localeMiddleware({ supported: ['en', 'fr', 'ar'], default: 'en' });
// GET /dashboard with Accept-Language: fr → 307 → /fr/dashboard

RTL layout

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

ts
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'