Flutter release build showed translation keys instead of text: assets missing from pubspec.yaml
After adding German and Spanish to our Flutter app, release builds showed raw keys like checkout.pay_button instead of text. The new translation files were declared as assets only on one laptop, never in the repository. How we found it, fixed the declarations and moved to compile-time localizations.
Our Flutter app had shipped in English and French for a year, using the flutter_i18n package with JSON
translation files. For a launch in Germany and Spain, a developer added de.json and es.json, tested in
the simulator with the device language set to German, and everything looked right.
The release build distributed to testers through TestFlight and the Play internal track showed this on German devices:
checkout.pay_button
checkout.total_label
profile.delete_account
Raw keys everywhere, instead of "Jetzt bezahlen" and the rest. Spanish devices showed the same. English and French were fine.
Reproducing locally
flutter run --release -d emulator-5554
On a clean checkout of the release branch, with the emulator set to German, the release build showed the keys too. The app's log:
I/flutter: [flutter_i18n] Error loading translation: Unable to load asset: "assets/i18n/de.json".
I/flutter: [flutter_i18n] The asset does not exist or has empty data.
Why did it work for the developer?
On the developer's laptop, git status showed the answer: pubspec.yaml was modified locally. While
testing, they had added the two new files to the asset list, but that change was left out of the commit,
which only contained the JSON files and code. Their local builds, debug and release, bundled the files; the
CI build from the repository did not.
The pubspec
flutter:
uses-material-design: true
assets:
- assets/images/
- assets/i18n/en.json
- assets/i18n/fr.json
Each translation file was listed individually. The two new files were not.
What we ruled out
- Locale detection. Logging
Localizations.localeOf(context)showeddeon German devices; the right locale was requested. - JSON syntax errors. Both files parsed with
dart format-style validators andjq. - Caching in the stores. The same problem reproduced in a local release build.
Root cause
Flutter bundles only assets declared in pubspec.yaml. The project listed translation files one by one,
and the new de.json and es.json were never added, so builds from the repository did not contain them;
the declaration had been added on the developer's machine but never committed. The localization package failed to load them and, without a configured fallback, displayed the keys.
How Flutter assets are bundled
At build time, Flutter reads the assets section of pubspec.yaml and packages the listed files and
directories into the app's asset bundle. Listing a directory (with a trailing slash) includes all files
directly in it, but not files in subdirectories. Anything not listed is not in the app.
Because the declaration lives in a different file from the translations, it is easy to commit one without the other. Builds from a clean checkout in CI are the reliable check, not builds on the machine where the change was made.
Fallbacks
Most runtime translation packages can be configured with a fallback locale and with what to show when a key or file is missing. Showing the key is the default for many, because it makes missing translations visible during development. For production, falling back to English is usually better than showing keys.
Who is affected
Flutter apps using runtime-loaded translation files (JSON, YAML, CSV) through packages such as
flutter_i18n or easy_localization, especially when files are listed individually in pubspec.yaml.
Apps using Flutter's built-in gen-l10n with ARB files are not affected in this way, because translations
are compiled into Dart code.
Environment
| Component | Version / value |
|---|---|
| Flutter | 3.35 stable |
| Localization | flutter_i18n with JSON files in assets/i18n/ |
| Locales | en, fr (existing), de, es (new) |
| Distribution | TestFlight and Play internal testing track |
Why tests did not catch it
The project had widget tests for screens, but they used a fake translation loader that returned keys, because loading real assets in widget tests needs extra setup. So the tests could not tell whether real translation files existed. The new release-build test below closes that gap.
Declare the directory, not individual files
flutter:
uses-material-design: true
assets:
- assets/images/
- assets/i18n/
Every JSON file in assets/i18n/ is now bundled, including any added later.
Configure a fallback
FlutterI18nDelegate(
translationLoader: FileTranslationLoader(
basePath: 'assets/i18n',
fallbackFile: 'en',
useCountryCode: false,
),
missingTranslationHandler: (key, locale) {
debugPrint('Missing translation for $key in ${locale?.languageCode}');
},
)
With fallbackFile: 'en', a missing German file or key shows English text instead of a key.
Later: move to gen-l10n
A few months later, we migrated to Flutter's built-in localization generation, which turns ARB files into Dart code at build time. Missing translations become build warnings, and there are no runtime assets to forget:
# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
untranslated-messages-file: build/untranslated.json
Text(AppLocalizations.of(context)!.payButton)
The untranslated-messages-file lists keys that are missing in each locale; CI fails if it is not empty
for a release.
Checking what is inside a build
To see which assets a build actually contains, inspect the bundle. For Android:
unzip -l build/app/outputs/flutter-apk/app-release.apk | grep flutter_assets/assets/i18n
For iOS, the files are under Runner.app/Frameworks/App.framework/flutter_assets/. Running this in CI
after the build makes a missing file obvious long before a tester sees raw keys.
Alternatives and trade-offs
- Keep runtime JSON with directory declarations. Fine for apps that download translations remotely or let translators update files without app releases.
- A translation management service that exports ARB or JSON files in CI, which removes manual file handling.
How to test the fix
A widget test loads each locale and checks a known string, but asset bundling issues only show in real builds, so we added an integration test that runs on a release build in CI:
flutter build apk --release
flutter drive --driver=test_driver/integration_test.dart \
--target=integration_test/locales_test.dart --release -d emulator-5554
testWidgets('every supported locale shows translated text', (tester) async {
for (final locale in ['en', 'fr', 'de', 'es']) {
await tester.pumpWidget(MyApp(locale: Locale(locale)));
await tester.pumpAndSettle();
expect(find.textContaining('checkout.'), findsNothing);
}
});
Confirming in production
The next TestFlight and Play internal builds showed German and Spanish text. The German and Spanish launch went ahead on schedule. The missing translation handler logged three keys missing in Spanish, which were added before release.
Process change
Pull requests that add a locale now use a template with three items: translation files, asset declaration (or ARB file), and a screenshot from the CI build, not from a local build. The template exists because the original change looked complete in review: every file that was committed was correct.
Regression guard
- A CI check compares files in
assets/i18n/with the supported locales list and fails on mismatches. - The release-build integration test runs for every pull request that touches localization.
- Release candidates are tested only on builds produced by CI.
Translator workflow
Translators now deliver files through a pull request that CI builds and tests like any other change, instead of sending JSON files to a developer by chat. The release-build test runs on that pull request, so a missing declaration or file is caught before review.
Security implications
None directly. Note that raw keys can reveal internal feature names and structure to users; a fallback locale avoids showing them.
Prevention checklist
- Declare asset directories rather than individual files in
pubspec.yaml. - Test localization on builds from a clean CI checkout, not only locally.
- Configure a fallback locale for runtime translation packages.
- Prefer
gen-l10nwith ARB files for compile-time checking. - Fail CI on untranslated messages before releases.
References
- Flutter, Adding assets and images: https://docs.flutter.dev/ui/assets/assets-and-images
- Flutter, Internationalizing Flutter apps: https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization
- flutter_i18n package: https://pub.dev/packages/flutter_i18n
- Flutter, Build and release an Android app: https://docs.flutter.dev/deployment/android
Add a label
Embed this post
<iframe src="https://www.hibug.me/flutter-i18n-translations-missing-in-release" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of the last decade on mobile teams, first native iOS, then React Native and Flutter for companies that needed one team to ship to both stores. The work I enjoy most is the unglamorous part between "it runs on my phone" and "it is live for a million users": code signing, release builds, store review, crash triage and background behaviour that differs on every Android vendor. Most of the bugs I write about here cost a release day. Each post shows the exact error, what we ruled out, the fix and how we proved it in the next build, so you can get your own release out the door sooner.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.