Fix Flutter iOS App Stuck in English (CFBundleLocalizations)
You ran flutter gen-l10n, wired up supportedLocales, and tested on Android — German, French, Japanese all switch perfectly. Then you install the same build on an iPhone set to German and the app is stubbornly English. Worse: after you ship, the App Store listing says Languages: English, even though every screen is translated.
Nothing is wrong with your Dart code. iOS simply doesn't know your app is localized, because iOS reads a completely different set of files than Flutter does. Three iOS-only pieces have to be in place: the CFBundleLocalizations array in Info.plist, per-locale .strings files, and the knownRegions list in project.pbxproj. This post walks that exact chain, then gives you a copy-paste CI check so the mismatch can never ship again.
Why iOS ignores your Flutter translations
On Android, Flutter receives the device's full preferred-language list and resolves it against your supportedLocales. That's the whole story, which is why Android "just works."
On iOS there's a gatekeeper in front of your Dart code. Per Apple's language-matching rules (Technical Q&A QA1828), the OS decides which language your app process runs in before Flutter boots, by intersecting the user's preferred languages with the localizations the app bundle declares. If the bundle only declares English, the intersection is empty and iOS falls back to CFBundleDevelopmentRegion — which is en in every Flutter project template.
So even with the device set to German, the locale handed to WidgetsApp is en, your localeResolutionCallback never even sees de, and the app looks "stuck in English." When you're debugging flutter ios localization not working, this bundle-level declaration is almost always the culprit — not gen-l10n, not your ARB files.
The bundle declares its localizations in two places: CFBundleLocalizations in Info.plist (fixes runtime language detection), and .lproj/.strings files tracked in project.pbxproj (fixes the App Store listing). You need both.
Step 1: Declare your languages in Info.plist
Open ios/Runner/Info.plist and add every locale from your MaterialApp.supportedLocales:
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>de</string>
<string>fr</string>
<string>ja</string>
<string>zh-Hans</string>
</array>
Two details trip people up:
- Hyphens, not underscores, for script and region variants:
zh-Hans,pt-BR. Your ARB file is namedapp_zh_Hans.arb, butInfo.plistfollows Apple's identifiers. - The list must cover every locale you support in Dart. A locale missing here behaves exactly like a locale you never translated.
Then delete the app from the device or simulator and reinstall — flutter clean alone doesn't refresh what iOS cached about the installed bundle, which is why a flutter locale not changing on iOS often survives a rebuild. After reinstalling, set the device to German and launch: your translations appear.
Quick smoke test: on iOS 13+, once the bundle declares multiple localizations, your app gets its own per-app language picker under Settings → [Your App] → Language. If that picker shows up, iOS has accepted your declaration.
Step 2: Fix "Languages: English" in App Store Connect
Here's the part most guides skip, and the reason your flutter app store listing shows only English even after Step 1: App Store Connect does not read CFBundleLocalizations when it builds the "Languages" row on your product page. It reads the Xcode project's localization data — the knownRegions list in ios/Runner.xcodeproj/project.pbxproj plus the per-locale .lproj folders containing .strings files. The official Flutter internationalization docs added an appendix for exactly this after developers kept hitting it.
The reliable fix is to let Xcode generate the plumbing:
- Open
ios/Runner.xcodeprojin Xcode. - In the Project Navigator, select the Runner project (the top-level project, not the target) and open the Info tab.
- Under Localizations, click + and add each language you support.
- When Xcode asks which files to localize and which reference language to use, just click Finish. Empty
.stringsfiles are completely fine — their existence is the signal.
Xcode creates files like ios/Runner/de.lproj/LaunchScreen.strings and updates project.pbxproj so it contains something like:
knownRegions = (
en,
Base,
de,
fr,
ja,
"zh-Hans",
);
Commit the new .lproj folders and the project.pbxproj change. The languages row updates with the next build you upload and release — it is not fixed retroactively for a build that's already live.
Step 3: A CI guard so the mismatch can never ship
Every time you add a language, three places must agree: your ARB files, Info.plist, and knownRegions. Humans forget. Put this dependency-free script in tool/check_ios_locales.dart and let CI remember for you:
// tool/check_ios_locales.dart
// Fails when Info.plist's CFBundleLocalizations is out of sync
// with the locales your ARB files actually provide.
import 'dart:io';
void main() {
// 1. Locales you ship, from lib/l10n/app_<locale>.arb
final arbLocales = Directory('lib/l10n')
.listSync()
.map((e) => RegExp(r'app_([A-Za-z_]+)\.arb$').firstMatch(e.path))
.whereType<RegExpMatch>()
.map((m) => m.group(1)!.replaceAll('_', '-')) // zh_Hans -> zh-Hans
.toSet();
// 2. Locales declared to iOS
final plist = File('ios/Runner/Info.plist').readAsStringSync();
final block = RegExp(
r'<key>CFBundleLocalizations</key>\s*<array>([\s\S]*?)</array>',
).firstMatch(plist);
final iosLocales = RegExp(r'<string>([^<]+)</string>')
.allMatches(block?.group(1) ?? '')
.map((m) => m.group(1)!)
.toSet();
final missing = arbLocales.difference(iosLocales);
final stale = iosLocales.difference(arbLocales);
if (missing.isNotEmpty) {
stderr.writeln('Missing from CFBundleLocalizations: $missing');
}
if (stale.isNotEmpty) {
stderr.writeln('In Info.plist but no matching ARB file: $stale');
}
if (missing.isNotEmpty || stale.isNotEmpty) exit(1);
print('iOS locale declarations OK: ${arbLocales.join(', ')}');
}
Wire it into GitHub Actions (or any CI) right next to your analyzer step:
- name: Check iOS locale declarations
run: dart run tool/check_ios_locales.dart
Want the App Store side guarded too? Add a second regex over ios/Runner.xcodeproj/project.pbxproj that extracts the knownRegions = ( ... ); block and diffs it against the same ARB set. Same pattern, one more file.
Gotchas checklist
- App still English after editing Info.plist? Delete and reinstall the app; hot restart and even
flutter cleandon't re-register bundle localizations with the OS. - Regional variants: if the user's device is
de-ATand you declare onlyde, iOS falls back to the generic language correctly — you don't need every region, just the base language plus any script variants (zh-Hansvszh-Hant). CFBundleDevelopmentRegionis your last-resort language. Leave it asenunless your primary market differs.- Simulator testing: change the language via Settings inside the simulator, then fully relaunch the app; a hot restart keeps the old locale.
- App Store timing: the listing reflects the binary's declared localizations, so the fix appears only after the next release goes live, not immediately after you push the commit.
Keep the Dart side just as tidy
The iOS chain above only pays off if the ARB files feeding it are complete and consistent. The FlutterLocalisation ARB editor keeps every locale's keys in sync, flags missing translations before they ship, and exports gen-l10n-ready files — and the pricing starts at free for small projects.
Try FlutterLocalisation free and make "works on Android, English on iOS" a bug class you never see again.