Adding Korean/English Support to a React Native (Expo) App — My Locale Detection Ordeal
"Can I really publish an app that's Korean-only?"
Trying to publish a YouTube playlist app I'd been using personally to the Play Store, I realized every bit of text in the app was in Korean. Beyond just passing review, there's no reason for an English-speaking user to use an app where they can't even tell what the buttons say.
So I set a simple goal: Korean if the system language is Korean, English otherwise. The app was too small to justify pulling in a library like i18next, and with only about 20 strings, I decided to build it myself.
There were three ways to do this
There are three main ways to read the system language in React Native.
| Method | Package | Problem |
|---|---|---|
expo-localization | expo-localization | linking fails in native builds |
NativeModules.I18nManager | none (built into RN) | returns undefined on Android |
Intl.DateTimeFormat() | none (built into Hermes) | none ✓ |
Short answer: the third option, the Intl API, was the right one. But the detours through the first two are probably more useful, so here they are in order.
Step 1. expo-localization — the first thing I tried
Since this is the method in Expo's official docs, of course it's what I tried first.
npx expo install expo-localization
import * as Localization from 'expo-localization';
const locale = Localization.getLocales()[0]?.languageCode ?? 'en';
const isKorean = locale === 'ko';
Worked fine testing with Expo Go. The problem was when I built it as an APK.
TypeError: Cannot read property 'getLocales' of undefined
expo-localization is a native module, so autolinking needs to be wired up correctly when npx expo prebuild generates the android/ directory. But if you add the package after prebuild has already run once, the linking can end up missing.
Regenerating with npx expo prebuild --clean might fix it, but that risks wiping out any settings you'd manually edited into the android/ folder.
Step 2. NativeModules.I18nManager — surely this is fine, it's built into RN?
I tried solving this with RN's own built-in module, with no external package.
import { NativeModules } from 'react-native';
const locale = NativeModules.I18nManager?.localeIdentifier ?? 'en';
On iOS, I18nManager.localeIdentifier returns a value like "ko_KR" just fine. But on Android, this property doesn't exist.
Android's I18nManager native module only handles RTL (right-to-left) layout functionality, and doesn't expose a property like localeIdentifier. So it always returned undefined, falling back to 'en'.
Step 3. The Intl API — Hermes already had it
Starting with React Native 0.71, the default JS engine switched to Hermes. Hermes has the Intl API built in, so you can read the system language with a standard JavaScript API, no separate package needed.
const loc = Intl.DateTimeFormat().resolvedOptions().locale;
// → "ko-KR", "en-US", "ja-JP", etc.
Calling Intl.DateTimeFormat() with no arguments creates a formatter using the system's default locale. Pull the locale code out with .resolvedOptions().locale.
Final implementation: src/i18n.ts
I bundled all three methods into a fallback chain, organized into one file.
import { NativeModules, Platform } from 'react-native';
function detectLocale(): string {
// Priority 1: Hermes's built-in Intl (RN 0.71+, most reliable)
try {
const loc = Intl.DateTimeFormat().resolvedOptions().locale;
if (loc && loc !== 'und' && loc.length >= 2) return loc;
} catch {}
// Priority 2: iOS fallback
if (Platform.OS === 'ios') {
const sm = (NativeModules as any).SettingsManager;
return sm?.settings?.AppleLocale ?? sm?.settings?.AppleLanguages?.[0] ?? 'en';
}
// Priority 3: Android fallback
return (NativeModules as any).I18nManager?.localeIdentifier ?? 'en';
}
export const detectedLocale = detectLocale();
const isKorean = detectedLocale.startsWith('ko');
export const t = {
playlistHeader: isKorean ? '재생 목록' : 'Playlist',
itemCount: (n: number) => isKorean ? `${n}개` : `${n}`,
addUrlBtn: isKorean ? '+ URL 추가' : '+ Add URL',
playerEmpty: isKorean ? '플레이리스트에 영상을 추가해주세요' : 'Add videos to get started',
listEmpty: isKorean ? '플레이리스트가 비어있습니다' : 'Your playlist is empty',
listEmptyHint: isKorean ? '아래 + 버튼으로 유튜브 URL을 추가해보세요' : 'Tap + below to add a YouTube URL',
modalTitle: isKorean ? 'YouTube URL 추가' : 'Add YouTube URL',
inputPlaceholder: isKorean ? 'YouTube URL을 붙여넣기 하세요' : 'Paste YouTube URL here',
addButton: isKorean ? '+ 플레이리스트에 추가' : '+ Add to Playlist',
urlHint: isKorean
? 'youtube.com/watch?v=... 또는 youtu.be/... 형식 지원'
: 'Supports youtube.com/watch?v=... or youtu.be/...',
invalidUrl: isKorean ? '유효한 유튜브 URL이 아닙니다.' : 'Invalid YouTube URL.',
alreadyExists: isKorean ? '이미 플레이리스트에 있습니다.' : 'Already in your playlist.',
};
Just import the t object and use it.
// App.tsx
import { t } from './src/i18n';
<Text>{t.playlistHeader}</Text> // → '재생 목록' or 'Playlist'
<Text>{t.itemCount(3)}</Text> // → '3개' or '3'
Step 4. Testing English on a real device
There's a way to test this without changing your system language. Android 12+ devices, including Samsung Galaxy phones, support per-app language settings.
You can change a specific app's language independently via ADB.
# Switch to English
adb shell cmd locale set-app-locales com.backdev.chainplay --locales en
# Revert to Korean
adb shell cmd locale set-app-locales com.backdev.chainplay --locales ko
Fully quit the app and reopen it, and the changed language takes effect. Convenient, since you can test just the app's language without touching the system language.
Note:
set-app-localesonly works on Android 12 (API 32) and above. On earlier versions, you need to change the system language directly.
Troubleshooting
Using Intl still returns 'und'
'und' (undetermined) means the locale couldn't be determined. This happens on emulators or some custom ROMs. Checking loc !== 'und' in the code falls through to the fallback correctly.
Still not changing even after an APK build
i18n.ts runs exactly once, when the module is first imported. You need to fully quit the app (swipe it away) and reopen it for the language change to take effect. Simply backgrounding and foregrounding the app doesn't re-execute it.
If you really need to use expo-localization
To fix the linking issue while keeping your existing android/ directory:
# Check whether the expo-localization autolinking lines exist
# in android/settings.gradle and android/app/build.gradle
grep -r "expo-localization" android/
If they're missing, you need npx expo prebuild --clean — just keep in mind this resets any custom settings in the android/ folder.
Summary
1. Intl.DateTimeFormat().resolvedOptions().locale ← use this
2. NativeModules.I18nManager.localeIdentifier ← undefined on Android
3. expo-localization ← watch for linking failures in native builds
For a simple app, a single t object is plenty without an i18n library. Once you have more languages, or need plural handling, that's when it's worth reaching for something like i18next.
Real-device testing can be done per-app via the adb shell cmd locale set-app-locales command, without touching the system language. Finding this out was a pretty nice convenience.
backtodev
A 40-something PM returns to code. Learning, failing, and growing.