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-react1. 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 */}
</>
);
}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 itemsPlaceholders 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.99currency defaults to USD. Missing parameters are left as {{name}}, and a
type mismatch warns and falls back to the raw value instead of throwing.
The third argument to t is used when the key is missing — handy for
progressively translated copy.
t('checkout.newBanner', {}, 'checkout.defaultBanner');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.
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 errorsRefreshing a base cascades to everything that extends it, so no stale merged dictionary is ever served:
refresh('en-US', 'core'); // also evicts checkout and profileconst { 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.
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>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)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
};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.
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.
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.
| 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. |
| 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. |
| 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.
Remote and lazily-loaded data is treated as untrusted:
- Prototype pollution protection —
__proto__,prototypeandconstructorkeys 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 explicitallowInsecure;javascript:,data:andfile:are always rejected. - Response size cap — oversized payloads (default 2 MiB) are rejected.
- Non-fatal storage — quota or disk errors never break translation loading.
Licensed under the GNU General Public License v3.0 — see LICENSE.
Copyright (C) 2026 Jerez Tech