i18n in native apps: mobile, React Native and Expo
Internationalizing a native mobile app: locale detection, regional formats, plurals, offline, and above all updating translations without resubmitting to the store.
On the web, fixing a translation typo takes two minutes: edit a file, redeploy, done. On mobile, that same fix can wait a week — however long it takes a new build to clear App Store review. That one difference reshapes how you approach internationalization in a native app.
New to the topic? Start with our guide to what i18n is: keys, namespaces, plurals and fallback apply here too. This article focuses on what’s specific to native — React Native, Expo, iOS and Android.
What changes versus the web
Three constraints unique to mobile rewrite the rules.
- The bundle. An app ships its assets at build time. If your translations are frozen into the bundle, adding or fixing a language means rebuilding and redeploying the app.
- Store review. Apple and Google review every submission. So a typo shipped to production can stay visible for days before the fix lands.
- Offline. A browser is almost always connected; a mobile app isn’t. Your translations have to survive the subway and the airplane.
The upshot: on mobile, the question isn’t just “how do we translate?” but “how do we ship a translation?”
Detecting the device language
First step: know which language to render. On React Native and Expo, you read the
system locale in one line, then hand it to the i18n engine — here react-i18next,
the same one you’d use on the web:
import * as Localization from "expo-localization";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
i18n.use(initReactI18next).init({
lng: Localization.getLocales()[0]?.languageCode ?? "en",
fallbackLng: "en",
resources: { /* … */ },
});
Two rules of thumb. First, always set a fallbackLng: a device may be set to a
language you don’t support yet. Second, let users override it: plenty of people
run their phone in one language but want your content in another. Store that
preference locally and read it back on launch.
Separate language from region, too. A device can be set to Canadian French
(fr-CA) or French French (fr-FR): same UI language, but currency, date format
and even vocabulary differ. expo-localization exposes both (languageCode and
regionCode) — use language for text, region for formats. And read the system
writing direction (Localization.isRTL) to flip the layout for Arabic or Hebrew
rather than inferring it yourself.
Regional formats
Dates, numbers, currencies: as on the web, hand them to the standard Intl API
instead of homegrown code. Good news — React Native’s JavaScript engine (Hermes)
now ships Intl:
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" })
.format(1299.9); // "$1,299.90"
Just check that Intl is built with full locale data in your Hermes config: on
some versions, locale support is trimmed by default to shrink the binary. That’s a
distinctly mobile trap — invisible on the web.
Plurals and gender by language
Plural rules are universal, but how they’re applied depends on the engine. ICU MessageFormat is still the right answer: one key describes every form, the engine picks per language.
{count, plural, one {# unread message} other {# unread messages}}
On React Native, just make sure your i18n library applies the CLDR rules of the target language rather than hard-coded English logic — otherwise Russian, Arabic and Polish will render wrong.
The real challenge: updating translations without a store round-trip
This is where native differs most. Two approaches coexist.
Bundled (in-app) translations. The language files live in the bundle. Simple, rock-solid offline — but every text fix needs a new version and another store review. A non-starter if you want to move fast.
Remote translations. Keys are served from a CDN and fetched at runtime. You publish a fix on the server side; the app picks it up on its next launch or refresh, with no store resubmission. It’s the mobile equivalent of the web’s “deploy = push a file.”
// On launch: try the CDN, fall back to the bundle on failure
const remote = await fetch(CDN_URL).then((r) => r.json()).catch(() => null);
i18n.addResourceBundle("en", "common", remote ?? bundledEn, true, true);
One honest caveat: fetching from a CDN isn’t instant “live” in production. The app applies the new text on the next load cycle, not while the screen is showing. That’s plenty for fixing without waiting on the store — but don’t promise your team a change that flips on a user’s device in real time.
Don’t confuse this with OTA code updates (like expo-updates), which push
JavaScript outside the store: useful, but heavier. For text, distributing the
translations alone over a CDN is far lighter and more granular.
Handling offline
Remote loading must never break the experience without a network. The robust shape combines both approaches:
- Bundle a baseline of translations — the app works from the first second, even offline.
- Refresh from the CDN on launch when there’s a connection.
- Cache the last bundle you received for future offline starts.
That way the user always has something to read, and the freshest version the moment a connection comes back.
Test every language on a device
A translation that’s correct on paper can still break the UI once rendered. German overflows a button, Arabic mirrors a row of icons, a missed string stays in English. On mobile, those flaws only show on a real screen.
- Pseudo-localization. Temporarily swap each string for a longer, accented version (“[Add to cart]” → “[Àdd tö çàrt…]”). Hard-coded text jumps out (it stays plain) and overflows surface before your first real translation.
- Per-locale screenshots. Automate a screenshot of key screens in each language on every build. It’s the most effective net against visual regressions — and it doubles as your store-listing assets.
- A real RTL device. Emulating Arabic in a simulator doesn’t reveal everything: test at least one screen on a phone set right-to-left.
Don’t forget the store listing
Internationalization rarely stops at the app itself. The App Store and Play Store listing — title, description, screenshots, keywords — is localized separately, in each platform’s console. It’s often what decides whether someone installs at all: an English listing on a Japanese store converts poorly, even if the app inside is perfectly translated. Treat that metadata as its own market, with the same reference translations as the product.
Web vs native: the recap
| Aspect | Web | Native / mobile |
|---|---|---|
| Ship a fix | Redeploy, immediate | Store (slow) or CDN on next launch |
| Offline | Rare | Assume it by default |
| Language detection | Browser header | Device locale + user preference |
Intl / formats | Native to the browser | Depends on the engine (check Hermes) |
| Size constraint | Low | The bundle counts |
The real story is delivery
Native adds a dimension the web ignores: logistics. A bundled baseline to start, a CDN to stay fresh, a cache to survive the tunnel — that trio is what reconciles fast iteration with robustness. And it’s rarely what you plan for on day one.
This is Sonenta’s turf: @sonenta/react-i18next runs as-is in React Native, your
keys arrive over a CDN (so they’re fixable without another store trip), and
in-context editing reaches the device itself. The whole mechanics of that delivery —
versions, releases, rollback — deserve their own chapter:
translation deployment strategies.