Localization is one of those features every app needs and nobody wants to think about. You add a library, wire up a JSON file, and move on. Then you upgrade React Native. You turn on the New Architecture. Google Play starts asking about 16 KB page sizes. Your Arabic users report that "2 items" reads wrong. And the language your user picked is gone after the app restarts.
I hit every one of these, so I built react-native-i18n-kit: a fully native, zero-dependency localization toolkit for React Native, written in Kotlin, Swift and strict TypeScript.
npm install react-native-i18n-kit
Why build another i18n library?
I looked at what was out there and kept finding the same gaps:
- JS guessing at native facts. Many libraries read a locale string and work out everything else in JavaScript. But the device already knows its calendar, time zone, decimal separator, 12/24-hour preference and measurement system. Why not ask it?
- Built before the New Architecture. The bridge is going away. A localization module should be a TurboModule with a codegen spec, not a legacy module behind an interop layer.
-
Dependency sprawl.
i18n-js, anIntlpolyfill, a storage library to remember the language, a separate "device info" module... Every one is something more to audit, upgrade and debug. -
Android 15's 16 KB page-size requirement. Prebuilt native
.sofiles that aren't 16 KB-aligned are a real release blocker now.
So the goals were simple: native where it matters, zero third-party dependencies, New Architecture first, and a small hooks API that's hard to misuse.
What you get
| π Native device locale detection | Preferred languages, country, calendar, time zone, number separators, 12h/24h clock, metric/imperial |
| π Instant language switching |
setLocale('fr') re-renders your tree, no reload |
| π§© CLDR pluralization + interpolation |
zero / one / two / few / many / other, picked by the rules of the active language |
| πͺ RTL aware |
isRTL per locale, automatic I18nManager.forceRTL, and a callback when a restart is needed |
| π‘ Live system-locale events | Change the OS language mid-session and useDeviceLocale() updates |
| πΎ Remembers the user's choice | Saved natively (SharedPreferences / UserDefaults), restored on the next launch |
| π― Smart locale matching |
zh-Hans-CN β zh-CN β zh, pt β pt-BR, legacy iw β he
|
| π’ Locale-aware formatting | Numbers, currency, percent, dates and relative time |
| π‘οΈ Typed errors | One I18nKitError class with stable .code values |
| π§΅ TypeScript strict | Every public API is fully typed |
And under the hood:
- TurboModule with a codegen spec. No bridge.
-
Zero third-party dependencies. Only
reactandreact-nativeas peers. -
16 KB page-size compliant. It ships no prebuilt
.sobinaries of its own. -
Kotlin on Android (reading
android.icuandConfiguration), Swift on iOS (readingFoundation.Locale).
Three steps to a localized app
1. Write plain JSON:
{
"home": {
"greeting": "Hello, {{name}}!",
"cart": {
"zero": "Your cart is empty",
"one": "1 item in your cart",
"other": "{{count}} items in your cart"
}
}
}
2. Wrap your app once:
import { LocalizationProvider } from 'react-native-i18n-kit';
import en from './translations/en.json';
import fr from './translations/fr.json';
const TRANSLATIONS = { en, fr };
export default function App() {
return (
<LocalizationProvider translations={TRANSLATIONS} defaultLocale="en" fallbackLocale="en">
<RootNavigator />
</LocalizationProvider>
);
}
3. Translate anywhere:
function Home() {
const { t, locale, setLocale } = useTranslation();
return (
<View>
<Text>{t('home.greeting', { name: 'Rushikesh' })}</Text>
<Text>{t('home.cart', { count: 3 })}</Text>
<Button
title={locale === 'en' ? 'FranΓ§ais' : 'English'}
onPress={() => setLocale(locale === 'en' ? 'fr' : 'en')}
/>
</View>
);
}
On first launch the provider picks the device language if you ship it, otherwise defaultLocale. After the user picks a language, that choice wins on every later launch.
The features I'm proudest of
Plurals that are actually correct
English has two plural forms. Arabic has six. French treats 0 as singular. Hindi does too. Hand-written rules like count === 1 ? 'one' : 'other' get these wrong.
react-native-i18n-kit uses Intl.PluralRules (built into Hermes) to choose the CLDR bucket for the active language. If a bucket is missing it falls back to other, and an explicit zero bucket always wins for 0, so you can write "Your cart is empty" without special-casing it.
Locale matching that does what you mean
Device locales are messy: zh-Hans-CN, FR-ca, pt_BR, and old Android devices still report Hebrew as iw. The matcher is case-insensitive, treats _ as -, and works down from the full tag to language-script, language-region and language, then tries a sibling region:
| Device says | You ship | You get |
|---|---|---|
zh-Hans-CN |
zh-CN |
zh-CN |
FR-ca |
fr |
fr |
pt |
pt-BR, pt-PT
|
pt-BR |
iw-IL |
he |
he |
The same matcher runs for the device language, the saved choice and every setLocale call, so all three behave the same way.
Formatting that respects the device
const { formatNumber, formatCurrency, formatDate, formatRelativeTime } = useTranslation();
formatNumber(1234567.891); // en: "1,234,567.891" fr: "1 234 567,891"
formatCurrency(1234.5, 'USD'); // "$1,234.50"
formatDate(new Date(), { hour: 'numeric', minute: '2-digit' }); // follows the device 12/24h setting
formatRelativeTime(-3, 'day', { numeric: 'auto' }); // "3 days ago"
Here's a detail I like: Hermes doesn't ship Intl.RelativeTimeFormat. Rather than pulling in a polyfill, the library quietly falls back to the platform's own formatter (ICU's RelativeDateTimeFormatter on Android, RelativeDateTimeFormatter on iOS). You call one function and get the right output on both platforms.
Bad input such as NaN, Infinity or an invalid currency code throws a typed INVALID_ARGUMENT error instead of printing "NaN" to your users.
Real device info, from the device
const { country, timeZone, numberFormatSettings, uses24HourClock, usesMetricSystem } =
useDeviceLocale();
This comes straight from the OS, not from JavaScript guessing at a locale string. If the user changes their system language while your app is open, the hook re-renders with the new values (Android ACTION_LOCALE_CHANGED, iOS NSCurrentLocaleDidChangeNotification).
Honest RTL handling
React Native can't flip native layout direction without a reload, and pretending otherwise causes bugs. So the library tells you exactly when a reload is needed:
<LocalizationProvider
translations={TRANSLATIONS}
defaultLocale="en"
onRTLReloadRequired={() =>
Alert.alert('Restart required', 'Reopen the app to apply the new layout direction.')
}
>
isRTL updates immediately for your own styles. Because the chosen language is saved, the restarted app comes back in the right direction.
Errors you can handle
Every failure is an I18nKitError with a stable code: INVALID_LOCALE, NATIVE_MODULE_UNAVAILABLE, PROVIDER_NOT_FOUND, INVALID_ARGUMENT and more. The library never throws at import time, and a missing translation key returns the key itself (with a one-time dev warning) instead of crashing a screen.
try {
await setAppLanguage(input);
} catch (err) {
if (err instanceof I18nKitError && err.code === 'INVALID_LOCALE') {
showValidationMessage();
}
}
Built with tests first
The library has positive and negative test suites for the translator, plural rules, locale matcher, formatters, provider and native API bindings, plus Android unit tests for the Kotlin side. A large part of the commit history is bug fixes that a failing test caught first: legacy Android language codes, locale-change events arriving off the main thread, whitespace-only locale strings and more.
The README also includes a copy-paste Jest recipe for mocking the native module in your app's tests. If your components only use useTranslation(), you don't need a mock at all.
Try the example app
The repo ships a runnable example in English, French, Arabic (RTL) and Hindi, with a live device-locale card, a plural counter and an API Explorer that calls every API on the device (including the expected failure paths) and shows PASS/FAIL for each.
git clone https://github.com/rushikeshpandit/react-native-i18n-kit
cd react-native-i18n-kit && yarn install
yarn example ios # or: yarn example android
Requirements
- React Native 0.85 (New Architecture)
- Android API 29+ Β· iOS 15.1+
- TypeScript 5+ (strict recommended)
What's next
I'd love your feedback, especially from teams shipping in RTL languages or many locales. If something doesn't fit your setup, open an issue. PRs are welcome too.
If this saves you an afternoon of localization pain, a β on GitHub really helps other developers find it.
π¦ npm: react-native-i18n-kit
π» GitHub: rushikeshpandit/react-native-i18n-kit
Feel free to reach out:
LinkedIn: https://www.linkedin.com/in/rushikesh-pandit-646834100/
GitHub: https://github.com/rushikeshpandit
Portfolio: https://www.rushikeshpandit.in
Thanks for reading! π
Top comments (0)