Flutter iOS Stuck in English? Fix CFBundleLocalizations
Your translations work perfectly on Android. You switch the iPhone's system language to Spanish, reopen the app — and it's still in English. Nothing in your Dart code is wrong, and that's exactly why this bug survives code review: the failure lives in ios/Runner/Info.plist, not in lib/.
This post explains why iOS behaves differently, gives you the exact CFBundleLocalizations array to add, shows why the simulator and debug builds can hide the problem until TestFlight, and ends with a checklist to keep your ARB files and Info.plist in sync every time you add a language.
Why it works on Android but not iOS
On Android, your Flutter app receives the user's system locale and Flutter's resolution algorithm matches it against supportedLocales. No extra native configuration is required for the locale to reach Dart.
iOS is stricter. As Apple's Technical Q&A QA1828 explains, iOS picks a language for your app by intersecting the user's preferred languages with the localizations your app declares at the native level — either via .lproj folders in the bundle or the CFBundleLocalizations key in Info.plist. If your app declares nothing, iOS falls back to the development region (CFBundleDevelopmentRegion, almost always English).
A fresh Flutter project declares only English on the iOS side. So even though your Dart supportedLocales lists ten languages, iOS never hands your app a Spanish locale to resolve. The bug is upstream of every line of Dart you wrote.
The fix: mirror supportedLocales in Info.plist
Say your MaterialApp looks like this:
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
// e.g. generated from app_en.arb, app_es.arb, app_de.arb, app_pt.arb, app_ar.arb
home: const HomePage(),
);
Open ios/Runner/Info.plist and add a CFBundleLocalizations array that mirrors that list, one <string> per locale:
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>es</string>
<string>de</string>
<string>pt</string>
<string>ar</string>
</array>
Three details that trip people up:
- Use hyphens, not underscores, for regional variants. Dart writes
Locale('pt', 'BR')and ARB files are namedapp_pt_BR.arb, but Info.plist wantspt-BR. - Keep
CFBundleDevelopmentRegionas your fallback language. In current Flutter templates it's set to$(DEVELOPMENT_LANGUAGE), which resolves toen— that's what users get when none of their preferred languages match. - Do a full stop-and-rebuild (
flutter cleanif in doubt), not a hot restart. Info.plist changes only take effect on a fresh native build.
The Xcode alternative (what the Flutter docs now recommend)
The official Flutter internationalization guide currently recommends declaring languages through Xcode instead: open ios/Runner.xcodeproj, select the Runner project (not target), go to the Info tab, and add each language under Localizations. Xcode creates empty .strings files and records the languages in project.pbxproj.
Both approaches tell iOS the same thing. The Info.plist array is easier to diff, review, and script against — which matters for the sync checklist below — while the Xcode route additionally creates .lproj folders, which is what the App Store reads for its language list. Doing both is harmless; the key point is that something on the native side must declare every locale.
Why the simulator masks it
Two common setups hide this bug until a real device or TestFlight build:
- Your test device is set to English anyway. English matches the development region fallback, so everything looks fine. The bug only surfaces when a user's preferred language is one you support in Dart but never declared to iOS.
- You test with an in-app language picker. If you set
MaterialApp(locale: ...)manually, you bypass system locale resolution entirely — Dart forces the locale and translations render on every platform. Then a user who relies on their iPhone's system language gets English, and you can't reproduce it.
This is also why so many reports read "flutter app language not changing on iOS" specifically after release: debug workflows lean on hot restart and forced locales, and the one path that's broken — iOS handing your app a non-English preferred locale — is the one path nobody exercised.
The silent second failure: your App Store language list
The damage isn't limited to runtime. The Languages row on your App Store product page is derived from the localizations in your uploaded binary — Apple notes that it's driven by the .lproj folders in your bundle, and that apps managing localization themselves (like Flutter apps) should make sure CFBundleLocalizations lists every supported language.
So a Flutter app that ships Spanish, German, and Arabic translations but declares nothing natively shows "Languages: English" on the App Store. Spanish-speaking users searching the store have no signal that your app supports their language — you localized the app and then hid that fact from the one surface that markets it.
Keep ARB locales and Info.plist in sync: the checklist
Every ARB file, supportedLocales entry, and Info.plist string must move together. When you add a language:
- Add the ARB file —
app_fr.arbin yourarb-dir(usuallylib/l10n), then runflutter gen-l10n(or just build; generation runs automatically withgenerate: true). - Confirm
supportedLocales— if you useAppLocalizations.supportedLocales, this updates itself from the ARB files. If you hand-maintain the list, addLocale('fr'). - Add
<string>fr</string>toCFBundleLocalizationsinios/Runner/Info.plist(underscore → hyphen for regional codes). - Rebuild from clean and test on iOS with the device language actually set to French — not via an in-app override.
- Check the fr ARB is complete — a present-but-hollow locale file ships fallback English strings even when resolution works. This is where a translation UI beats raw JSON: the FlutterLocalisation ARB editor shows missing keys per locale at a glance, and its ICU plural validation flags locales missing a plural category the language grammatically requires (French needs
manyfor some constructions; Arabic, Polish, and Russian needfew/manyforms that English never exercises).
A 30-second CI guard catches drift between steps 1 and 3:
#!/usr/bin/env bash
# Fail the build if an ARB locale is missing from Info.plist
for f in lib/l10n/app_*.arb; do
locale=$(basename "$f" .arb); locale=${locale#app_}; locale=${locale//_/-}
grep -q "<string>$locale</string>" ios/Runner/Info.plist \
|| { echo "MISSING in Info.plist: $locale"; exit 1; }
done
echo "ARB locales and CFBundleLocalizations are in sync"
Still stuck? Two more things to rule out
GlobalCupertinoLocalizationsmissing. iOS-styled widgets need it inlocalizationsDelegates; the generatedAppLocalizations.localizationsDelegatesincludes it.- Locale resolution order. Flutter's default resolution takes the first supported match from the user's preferred-language list. If iOS users get an unexpected supported language (rather than English), inspect the device's full language list under Settings → General → Language & Region — iOS matches against the whole ranked list, not just the top entry.
Once CFBundleLocalizations mirrors your Dart locales, iOS resolves languages exactly like Android does, and your App Store listing finally advertises every language you worked to support.
The native declaration is a five-minute fix — keeping ten ARB files complete and plural-correct as your app grows is the ongoing work. FlutterLocalisation gives you an ARB editor and translation management for every locale, with ICU plural validation built in (pricing starts free). Try FlutterLocalisation free and keep every locale shippable on both stores.