DEV Community

Cover image for I built a fully native, zero-dependency i18n kit for React Native's New Architecture
Rushikesh Pandit
Rushikesh Pandit

Posted on

I built a fully native, zero-dependency i18n kit for React Native's New Architecture

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
Enter fullscreen mode Exit fullscreen mode

Why build another i18n library?

I looked at what was out there and kept finding the same gaps:

  1. 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?
  2. 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.
  3. Dependency sprawl. i18n-js, an Intl polyfill, a storage library to remember the language, a separate "device info" module... Every one is something more to audit, upgrade and debug.
  4. Android 15's 16 KB page-size requirement. Prebuilt native .so files 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 react and react-native as peers.
  • 16 KB page-size compliant. It ships no prebuilt .so binaries of its own.
  • Kotlin on Android (reading android.icu and Configuration), Swift on iOS (reading Foundation.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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.')
  }
>
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)