Localize a Flutter app: complete guide with Localingos
Flutter's i18n story is solid: ARB (Application Resource Bundle) files for source strings, flutter_localizations for the runtime, and flutter gen-l10n for typed Dart code generation. The translation pipeline is where teams stall. This guide pairs Flutter's built-in i18n with Localingos to automate the translation step while keeping the standard ARB workflow intact.
Step 1 — Install
npm install -g localingos
Flutter ships with everything else needed in the SDK.
Step 2 — Configure pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.19.0
flutter:
generate: true
Create l10n.yaml in project root:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false
Step 3 — Source of truth
lib/l10n/app_en.arb:
{
"@@locale": "en",
"welcome": "Welcome, {name}",
"@welcome": {
"placeholders": { "name": { "type": "String" } }
},
"cart": "{count, plural, =0 {Cart is empty} =1 {1 item in cart} other {{count} items in cart}}",
"@cart": {
"placeholders": { "count": { "type": "int", "format": "compact" } }
}
}
ARB uses ICU MessageFormat for plurals — same syntax as iOS/Android string resources.
Step 4 — Generate Dart bindings
flutter gen-l10n
This produces lib/l10n/app_localizations.dart with a typed class. From any widget:
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class Welcome extends StatelessWidget {
final String userName;
final int itemCount;
const Welcome({super.key, required this.userName, required this.itemCount});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(children: [
Text(l10n.welcome(userName)),
Text(l10n.cart(itemCount)),
]);
}
}
Typed access — l10n.welcome(userName) is checked at compile time. No string keys to typo.
Step 5 — Configure MaterialApp
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
);
}
}
Flutter automatically picks the closest supported locale based on Platform.localeName.
Step 6 — Configure Localingos
Run localingos init and answer its prompts. It writes two files.
localingos.config.json — commit this. Project settings shared with your team and CI:
{
"projectId": "your-project-id",
"sourceLocale": "en-US",
"format": "arb",
"sourceFile": "lib/l10n/app_en.arb",
"outputDir": "lib/l10n",
"outputPattern": "app_{locale}.arb"
}
.localingos.json — add this to .gitignore. Your API key for local development:
{ "apiKey": "your-api-key" }
In CI, set LOCALINGOS_API_KEY instead; it takes precedence over both files. init is interactive, so for containers, provisioning scripts or AI agents, write these two files yourself — the CLI reads nothing else.
The CLI reads and writes ARB directly, so there is no conversion step: your catalogue stays in the format Flutter already expects.
Things worth knowing:
@keymetadata is preserved.placeholdersdefinitions carry the types and formatsflutter gen-l10ndepends on, so they are copied onto every generated bundle. Dropping them would break codegen.@key.descriptionis used as the translator description, and@@localeis written for you — so ARB needs no descriptions sidecar.- ICU plural and select syntax is preserved as text, and its placeholders are validated. Translation does not add plural categories your source doesn't already contain.
- Target locales are not configured here. They belong to the project — set them in the dashboard under Projects → Edit → Locales → Update Project. Codes are full BCP 47, e.g.
es-ES,pt-BR,el-GR. - Placeholder preservation is automatic. Localingos extracts the placeholders in your source string and validates each one survives translation, retrying with a corrective prompt when it doesn't.
Translation is asynchronous
The first sync of a new key pushes it and usually has nothing to pull back yet:
Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation
No new translations. Run "localingos sync" again later.
That's expected. Run it again shortly to collect results. Verify completeness before committing — pull writes whatever is ready and exits 0, so a catalogue pulled mid-translation can be missing keys with no warning.
Then push your source strings and pull back translations:
localingos sync
Translation is asynchronous
The first sync of a new key pushes it and usually has nothing to pull back yet:
Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation
No new translations. Run "localingos sync" again later.
That's expected. Run it again shortly to collect results. Verify completeness before committing — pull writes whatever is ready and exits 0, so a catalogue pulled mid-translation can be missing keys with no warning.
Three Flutter-specific things:
format: "arb"— Localingos preserves ARB's metadata blocks (@welcome,@cart) verbatim while translating the visible strings.- ICU plural/select syntax is preserved as text. Placeholder validation protects the tokens inside it, but note that translation does not add plural categories your source doesn't already contain — see the plural note below.
- Output filename pattern uses
app_{locale}.arb— matchesflutter gen-l10n's convention.
Each target locale's ARB file appears in lib/l10n/. Re-run flutter gen-l10n to regenerate the Dart bindings, then commit both ARB files AND the generated Dart.
Step 7 — Runtime locale switching
If you want users to override their system locale in-app:
class LocaleProvider extends ChangeNotifier {
Locale _locale = const Locale('en');
Locale get locale => _locale;
void setLocale(Locale newLocale) {
if (!AppLocalizations.supportedLocales.contains(newLocale)) return;
_locale = newLocale;
notifyListeners();
}
}
// In MaterialApp:
MaterialApp(
locale: context.watch<LocaleProvider>().locale,
// ...
)
Persist with shared_preferences if you want the choice to survive app restart.
Step 8 — RTL support
Flutter handles RTL automatically when the active locale is Arabic, Hebrew, or Persian — Directionality.of(context) returns TextDirection.rtl, and Row/Column flip accordingly. Use EdgeInsetsDirectional instead of EdgeInsets so padding flips with the layout.
Container(
padding: const EdgeInsetsDirectional.only(start: 16, end: 8), // flips for RTL
child: Text(l10n.welcome(userName)),
)
Step 9 — Automate sync in CI
# .github/workflows/i18n.yml
name: i18n-sync
on:
push: { branches: [main], paths: ['lib/l10n/app_en.arb'] }
jobs:
sync:
runs-on: ubuntu-latest
permissions: { contents: write, pull-requests: write }
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable }
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm install -g localingos
- run: localingos sync
env: { LOCALINGOS_API_KEY: '${{ secrets.LOCALINGOS_API_KEY }}' }
- run: flutter gen-l10n
- uses: peter-evans/create-pull-request@v6
with:
branch: i18n/auto-sync
title: 'chore(i18n): sync translations + regen Dart bindings'
commit-message: 'chore(i18n): sync translations'
The flutter gen-l10n step regenerates Dart bindings so the new translations are immediately usable.
Production checklist
- Test in multiple locales. Use Flutter's
Localeoverride in widget tests:tester.binding.window.localeTestValue = const Locale('es'). - Pseudo-localization for layout testing. Generate a
app_psaccent.arbwith stretched/accented text — surfaces overflow bugs before localized text causes them. - iOS Info.plist. Add
CFBundleLocalizationsarray with every locale you ship — otherwise iOS won't even let users pick those languages in Settings. - App Store screenshots per locale. Once you have translated UI, generate localized App Store / Play Store screenshots — biggest install-rate win for international markets.
Wrap up
A Flutter app with type-safe ARB-based i18n, ICU plural syntax preserved through translation, RTL support, and CI-driven translation sync. The setup uses Flutter's recommended toolchain end to end — nothing exotic.
Plurals need a deliberate decision. Localingos translates the forms you send it. It does not add plural categories your source language doesn't have — a string with two forms comes back with two forms, even in a language that needs four (Polish) or six (Welsh). Author every form your target languages require in your source file and let your i18n library select among them at runtime, or keep count-bearing copy out of translation and format numbers separately.
Free tier: 5,000 words, counted once per target locale — so a small Flutter app in three or four languages, not a whole corpus in all 56. Which plan do I need? works it out for your own string count.