Skip to content

Repository files navigation

@jereztech/i18n-react

NPM Version GPL License Test Coverage

TypeScript i18n for the React ecosystem — React, Next.js and React Native — with lazy namespaces, Intl formatting, remote overrides and a hardened parser for untrusted translation data.

npm install @jereztech/i18n-react

Quick start

1. Write your translations.

// locales/home/en-US.json
{
    "greeting": "Hello {{name}}!",
    "cart": {
        "zero": "Your cart is empty",
        "one": "One item in your cart",
        "other": "{{count}} items in your cart"
    },
    "total": "Total: {{price, currency}}"
}

2. Create the config.

// i18n.config.ts
import { createNamespacedLoader, type I18nConfig } from '@jereztech/i18n-react';

const i18nConfig: I18nConfig = {
    defaultLocale: 'en-US',
    dictionaries: createNamespacedLoader(import.meta.glob('./locales/*/*.json')),
};

export default i18nConfig;

3. Wrap your app.

import { I18nProvider } from '@jereztech/i18n-react';
import i18nConfig from './i18n.config';

<I18nProvider config={i18nConfig} fallback={<Spinner />}>
    <App />
</I18nProvider>

4. Translate.

"use client";
import { useI18n } from '@jereztech/i18n-react';

export default function Home() {
    const { t } = useI18n('home');

    return (
        <>
            <h1>{t('greeting', { name: 'John' })}</h1>   {/* Hello John! */}
            <p>{t('cart', { count: 0 })}</p>             {/* Your cart is empty */}
            <p>{t('total', { price: 99.99 })}</p>        {/* Total: $99.99 */}
        </>
    );
}

Features

Pluralization — including zero

A translation value can be an object of plural forms. The category is picked from count: zero for 0, one for ±1, other for everything else. Rules are locale-agnostic, so the same three buckets apply to every language. Always provide other — it is the fallback when the selected category is absent.

{ "cart": { "zero": "Empty", "one": "One item", "other": "{{count}} items" } }
t('cart', { count: 0 });   // Empty
t('cart', { count: 1 });   // One item
t('cart', { count: 7 });   // 7 items

Interpolation and Intl formatting

Placeholders are {{name}}, optionally with a format type: {{value, date}}, {{value, number}} or {{value, currency}}. Formatting runs through Intl using the active locale.

{
    "published": "Published on {{date, date}}",
    "score": "Score: {{score, number}}",
    "price": "Price: {{amount, currency}}"
}
t('published', { date: new Date('2026-05-09') }); // Published on May 9, 2026
t('score', { score: 12345 });                     // Score: 12,345
t('price', { amount: 99.99, currency: 'EUR' });   // Price: €99.99

currency defaults to USD. Missing parameters are left as {{name}}, and a type mismatch warns and falls back to the raw value instead of throwing.

Fallback keys

The third argument to t is used when the key is missing — handy for progressively translated copy.

t('checkout.newBanner', {}, 'checkout.defaultBanner');

Namespaces

Organize translations per feature. Pass the namespace to useI18n and it is loaded on demand, cached, and de-duplicated across concurrent callers.

locales/
  checkout/en-US.json
  checkout/es-ES.json
  home/en-US.json
  home/es-ES.json
const { t, isLoading } = useI18n('checkout');
return isLoading ? <Spinner /> : <h1>{t('title')}</h1>;

createNamespacedLoader builds the loader from any <namespace>/<locale>.json map — a Vite glob, a Webpack require.context, or a hand-written object:

// Vite
createNamespacedLoader(import.meta.glob('./locales/*/*.json'));

// Webpack / Next.js
const ctx = require.context('./locales', true, /\.json$/);
createNamespacedLoader(Object.fromEntries(
    ctx.keys().map((k) => [k, () => Promise.resolve(ctx(k))]),
));

// Metro / Expo — explicit imports so the bundler can find them statically
createNamespacedLoader({
    '/locales/home/en-US.json': () => import('./locales/home/en-US.json'),
    '/locales/home/es-ES.json': () => import('./locales/home/es-ES.json'),
});

If a namespace file is missing for the active locale, the runtime falls back to defaultLocale for that namespace only — the rest of the app stays translated.

Namespace inheritance

Share common keys without duplicating them in every file. A child namespace deep-merges its bases, with precedence child > rightmost base > … > leftmost base. Bases resolve transitively, and cycles throw at startup.

const i18nConfig: I18nConfig = {
    defaultLocale: 'en-US',
    dictionaries,
    namespaceExtensions: {
        checkout: ['core', 'errors'],  // core ⊕ errors ⊕ checkout
        profile: ['core'],
    },
};
const { t } = useI18n('checkout');
t('title');       // from checkout
t('save');        // inherited from core
t('required');    // inherited from errors

Refreshing a base cascades to everything that extends it, so no stale merged dictionary is ever served:

refresh('en-US', 'core'); // also evicts checkout and profile

Switching locales

const { locale, setLocale, supportedLocales } = useI18n();

<select value={locale} onChange={(e) => setLocale(e.target.value)}>
    {supportedLocales.map((l) => <option key={l} value={l}>{l}</option>)}
</select>

setLocale resolves once the new locale's dictionaries are in place, so there is no flash of untranslated content. On mount the provider auto-detects the locale from navigator.languages, matching exactly first and then by primary language (fr-CA → fr-FR), falling back to defaultLocale.

Preloading and Suspense

const { preload } = useI18n();
onMouseEnter={() => preload('en-US', ['profile'])} // warm the next route
// i18n.config.ts
const i18nConfig: I18nConfig = {
    /* … */
    suspense: true,
    preloadNamespaces: ['home'],
};

<Suspense fallback={<Spinner />}>
    <Checkout /> {/* useI18n('checkout') suspends until ready */}
</Suspense>

Remote overrides (CDN / CMS)

Ship local files as a safe baseline and deep-merge remote values on top — or replace them entirely with strategy: 'override'. Any remote failure falls back to local, so a CDN outage never blanks your UI.

const i18nConfig: I18nConfig = {
    defaultLocale: 'en-US',
    dictionaries,
    remote: {
        url: 'https://cdn.example.com/i18n/{locale}/{namespace}.json',
        strategy: 'merge',
        ttlMs: 5 * 60 * 1000,
        version: '2026-05-09',                    // ?v=… cache busting
        headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
        maxResponseBytes: 512 * 1024,
        onError: (error, locale, namespace) => {
            reportToSentry(error, { locale, namespace });
            return true;                          // swallow — local already rendered
        },
    },
};

Invalidate after a content release:

const { refresh } = useI18n();
await refresh();                 // everything
await refresh('en-US');          // one locale
await refresh('en-US', 'home');  // one (locale, namespace)

Persistent cache

Pass anything implementing getItem / setItem / removeItem:

import { localStorageAdapter } from '@jereztech/i18n-react';

const i18nConfig: I18nConfig = {
    /* … */
    storage: localStorageAdapter() ?? undefined, // null on RN/SSR
    storageKeyPrefix: 'myapp-i18n:v3',           // bump to invalidate
};

Platform recipes

Next.js (App Router)

I18nProvider is a client component, so mount it once in the root layout:

// app/[locale]/layout.tsx
import { I18nProvider } from '@jereztech/i18n-react';
import i18nConfig from '@/i18n.config';

export default async function RootLayout({
    children, params,
}: { children: React.ReactNode; params: Promise<{ locale: string }> }) {
    const { locale } = await params;

    return (
        <html lang={locale}>
            <body>
                <I18nProvider config={i18nConfig}>{children}</I18nProvider>
            </body>
        </html>
    );
}

Build the loader with require.context (Webpack) and keep config a module-level constant so the provider is not re-created on every render.

React Native / Expo

Metro cannot resolve globs, so list the dictionaries explicitly, seed navigator.language from expo-localization, and persist with AsyncStorage:

// i18n.config.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getLocales } from 'expo-localization';
import { createNamespacedLoader, type I18nConfig } from '@jereztech/i18n-react';

(globalThis as any).navigator ??= {};
(globalThis as any).navigator.language = getLocales()[0]?.languageTag ?? 'en-US';

const i18nConfig: I18nConfig = {
    defaultLocale: 'en-US',
    dictionaries: createNamespacedLoader({
        '/locales/home/en-US.json': () => import('./locales/home/en-US.json'),
        '/locales/home/es-ES.json': () => import('./locales/home/es-ES.json'),
    }),
    storage: {
        getItem: (k) => AsyncStorage.getItem(k),
        setItem: (k, v) => AsyncStorage.setItem(k, v),
        removeItem: (k) => AsyncStorage.removeItem(k),
    },
};

export default i18nConfig;

Alternatively, skip the navigator shim and call setLocale(languageTag) explicitly at startup.

Node / Edge SSR

fetch is global on Node 18+ and all major edge runtimes. For older Node, supply remote.fetcher (e.g. undici's fetch). Create one runtime per request with createI18nRuntime(config) to keep caches isolated.

API

useI18n(namespace?)

Returns Type Description
t (key, params?, fallbackKey?) => string Translate, interpolate and format.
locale string Active locale.
setLocale (locale) => Promise<void> Switch locale; resolves once loaded.
supportedLocales string[] Locales declared in dictionaries.
loadNamespace (namespace) => Promise<void> Load a namespace on demand.
preload (locales, namespaces?) => Promise<void> Warm the cache in parallel.
refresh (locale?, namespace?) => Promise<void> Invalidate and reload.
isLoading boolean True while a load is in flight.

I18nConfig

Field Type Default Description
defaultLocale string 'en' Locale used for fallbacks and when detection fails.
dictionaries DictionaryMap | DictionaryLoader {} Eager map or lazy namespaced loader. Declares supported locales.
autodetectLanguage true true Detect from navigator. Set to undefined to disable.
initializeWithDefault true true Use defaultLocale when detection is off or fails. Set to undefined to disable.
preloadNamespaces string[] [] Namespaces loaded eagerly on mount.
namespaceExtensions NamespaceExtensions – Declarative namespace inheritance.
remote RemoteConfig – CDN/CMS source overriding or augmenting local files.
storage StorageAdapter – Persistent cache (localStorage, AsyncStorage, …).
storageKeyPrefix string 'i18n-react:v1' Cache namespace; bump to invalidate.
suspense boolean false Integrate namespace loading with React Suspense.

RemoteConfig

Field Type Default Description
url string | (locale, ns) => string – Template with {locale} / {namespace}, or a builder.
strategy 'merge' | 'override' 'merge' Augment or replace local translations.
ttlMs number 300000 Cache lifetime.
version string – Appended as ?v=<version>.
headers object | () => object – Static or per-call headers (auth, ETag).
allowInsecure boolean false Permit http://. Local development only.
maxResponseBytes number 2097152 Response size cap.
fetcher Fetcher global fetch Custom fetch (auth, retries, polyfill).
onError (error, locale, ns) => boolean – Return true to swallow errors silently.

Also exported: createI18nRuntime, createNamespacedLoader, localStorageAdapter, memoryStorageAdapter, translate, getDictionary, resolveUserLocale, selectPluralCategory, safeDeepMerge, validateDictionary, validateRemoteUrl, ROOT_NAMESPACE.

Security

Remote and lazily-loaded data is treated as untrusted:

  • Prototype pollution protection — __proto__, prototype and constructor keys are rejected on merge and on validation.
  • Schema validation — every payload must be a plain object with string-or-plural-object leaves before it reaches the cache.
  • HTTPS by default — http:// requires an explicit allowInsecure; javascript:, data: and file: are always rejected.
  • Response size cap — oversized payloads (default 2 MiB) are rejected.
  • Non-fatal storage — quota or disk errors never break translation loading.

License

Licensed under the GNU General Public License v3.0 — see LICENSE.

Copyright (C) 2026 Jerez Tech

About

TypeScript-based internationalization (i18n) package for the React ecosystem.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages