Flutter’s localization is built on ARB files and the intl package, and it uses ICU MessageFormat for plurals and selection. That last point matters: it means plural categories are named CLDR categories rather than a home-grown notation, which puts Flutter ahead of several web frameworks on the part that is hardest to retrofit.
Setup
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
flutter:
generate: true
# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false
nullable-getter: false is worth setting. Without it every lookup is AppLocalizations.of(context)! with a null assertion, which is noise on every single call site.
Then:
flutter gen-l10n
Wiring it up
import "package:flutter_gen/gen_l10n/app_localizations.dart";
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
);
final l10n = AppLocalizations.of(context);
Text(l10n.dashboardTitle);
Lookups are generated Dart methods, not string keys. That is the best property of this system: a typo is a compile error rather than a missing string at runtime, and renaming a key is a refactor the analyzer can verify.
ARB files
{
"@@locale": "en",
"dashboardTitle": "Dashboard",
"@dashboardTitle": {
"description": "Title of the main dashboard screen"
},
"welcome": "Welcome back, {name}",
"@welcome": {
"description": "Greeting shown on the dashboard",
"placeholders": {
"name": { "type": "String" }
}
}
}
Each message may be followed by an @-prefixed metadata entry. The description is the single most valuable field in the file — it is what a translator sees, and it is the mechanism for solving the ambiguity problem where a string like Open could be a verb or an adjective. Fill it in. See visual context for translators.
Plurals
{
"messageCount": "{count, plural, =0{No messages} one{1 message} other{{count} messages}}",
"@messageCount": {
"placeholders": {
"count": { "type": "int" }
}
}
}
Text(l10n.messageCount(unread.length));
This is real ICU, so the categories are the CLDR names and the target language supplies whatever set it needs — a Polish translation has one, few, many, other; Arabic has six. See plural rules by language.
The =0 exact match is worth using where the empty state needs different wording rather than just a different plural form.
Select and gender
{
"profileUpdated": "{gender, select, female{She updated her profile} male{He updated his profile} other{They updated their profile}}",
"@profileUpdated": {
"placeholders": {
"gender": { "type": "String" }
}
}
}
other is required and should read correctly for missing or unexpected values, not merely as a fallback nobody checked.
Formatted placeholders
Declare the type and format rather than pre-formatting in Dart:
{
"lastSync": "Last synced {date}",
"@lastSync": {
"placeholders": {
"date": { "type": "DateTime", "format": "yMMMd" }
}
},
"total": "Total: {amount}",
"@total": {
"placeholders": {
"amount": {
"type": "double",
"format": "compactCurrency",
"optionalParameters": { "symbol": "€" }
}
}
}
}
The generated code calls intl‘s formatters with the active locale, so separators, symbol placement and calendar conventions come out right per locale. Formatting in Dart and interpolating the string instead bakes in one locale’s conventions — see what is a locale for what actually varies.
Escaping
ICU treats braces as syntax, so a literal brace needs escaping. Set:
# l10n.yaml
use-escaping: true
Then '{' produces a literal brace. Without this flag, a message containing a literal brace fails to parse in a way that is not obviously about escaping.
The related trap is the apostrophe. In ICU a single apostrophe starts a quoted section, so French and Italian translations full of contractions can silently disable everything after the first one. Double it: L''utilisateur.
Locale resolution
Flutter picks from supportedLocales using the device locale. You can override the matching:
MaterialApp(
localeResolutionCallback: (deviceLocale, supported) {
if (deviceLocale == null) return supported.first;
for (final locale in supported) {
if (locale.languageCode == deviceLocale.languageCode) return locale;
}
return supported.first;
},
);
The default resolution matches language first and then country, which is usually right. It is worth overriding when you support script variants — a zh-Hant reader should not silently fall back to zh-Hans, because the two are not comfortably interchangeable.
To let users pick a language in-app, hold the chosen locale in your state management and pass it as MaterialApp.locale, overriding the device setting.
What gets forgotten
supportedLocalesnot updated when a language is added, so the ARB exists and is never selected.- Plurals used for languages whose ARB only has
oneandother, because the target files were generated by copying the English structure. - Platform strings — the iOS app name, permission prompts and notification text live in
Info.plistand Android resources, outside ARB entirely. - Error messages built by string concatenation in Dart rather than as messages.
- Date and number formatting done manually.
Right-to-left and layout
Flutter handles RTL better than most frameworks, provided you use the directional widgets rather than the physical ones.
Directionality is set automatically from the locale, and layout follows — but only for widgets that respect it:
// ✗ Physical: stays on the left in Arabic
padding: EdgeInsets.only(left: 16)
Align(alignment: Alignment.centerLeft)
// ✓ Directional: flips correctly
padding: EdgeInsetsDirectional.only(start: 16)
Align(alignment: AlignmentDirectional.centerStart)
The rule is to use start and end rather than left and right everywhere, including BorderRadiusDirectional and PositionedDirectional. A codebase written with physical alignment looks fine until the first RTL locale, at which point every screen needs revisiting.
Icons that indicate direction need mirroring too — back arrows, progress chevrons, undo. Icons that do not — media playback, checkmarks, logos — must be left alone. Transform.flip driven by Directionality.of(context) handles the first group.
Test it without translating anything: force Locale("ar") with a Directionality override and look at the screens. That is pseudo-localization applied to layout, and it finds the problems before any Arabic exists.
Testing
Pump widgets with an explicit locale and the real delegates:
await tester.pumpWidget(MaterialApp(
locale: const Locale("fr"),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Dashboard(),
));
Because lookups are generated methods, a missing key cannot reach a test — it fails to compile. What tests should cover instead is that plural forms resolve for the numbers that matter (0, 1, 2, 5, 11, 21 exercise most category boundaries) and that layout survives long translations.
Syncing translations
ARB is JSON, so it syncs directly:
wti push # lib/l10n/app_en.arb up
wti pull # translated ARB files back
wti diff # what a push would change
Then flutter gen-l10n in your build so the generated Dart matches the ARB. Because lookups are generated methods, a translation file that has drifted from the code produces a compile error rather than a runtime surprise — which is the right failure mode and worth leaning on.
Push on merge to your designated branch, pull nightly into a pull request; see Git-based localization workflows.
One Flutter-specific detail: the @-prefixed metadata entries belong only in the template ARB, not in the translated ones. flutter gen-l10n reads types, formats and descriptions from the template file, so duplicating them into every locale creates several places for the same information to drift. Translated ARB files should carry the messages and the @@locale header, and nothing else.
Frequently asked questions
- What is an ARB file in Flutter?
- ARB (Application Resource Bundle) is a JSON-based format where each key maps to a message string, optionally accompanied by an @key entry holding metadata — a description for translators and type and format information for each placeholder.
- How do I add localization to a Flutter app?
- Add flutter_localizations and intl to pubspec.yaml, set generate: true under the flutter section, create l10n.yaml pointing at your ARB directory, then run flutter gen-l10n. The generated AppLocalizations class is wired into MaterialApp and read with AppLocalizations.of(context).
- Does Flutter support ICU MessageFormat?
- Yes. ARB messages use ICU syntax for plural, select and gender, so plural categories are named CLDR categories rather than positional forms. This is a significant advantage over frameworks that invented their own plural notation.
- How do I format numbers and dates in Flutter?
- Declare the placeholder type as int, double or DateTime in the @key metadata and give it a format such as compactCurrency or yMMMd. The generated code calls the intl package's formatters with the active locale, so you get correct separators and calendar behaviour per locale.
Keep reading
-
ICU MessageFormat: a practical guide
The syntax for plurals, gender selection, number and date formatting in one message string — with the escaping rules and the mistakes translators reliably make.
-
Plural rules by language: the complete CLDR guide
A reference table of CLDR plural categories for 163 languages, plus why one does not mean 1 and how each i18n framework expects you to spell the rules.
-
React i18next: setup and workflow
Setting up i18next in a React app: namespaces, plurals, interpolation, Trans for embedded markup, lazy loading, and keeping translation files in sync.
-
Translate JSON and .arb files (documentation)
How WebTranslateIt parses JSON structures including Flutter .arb files.
Translate your app without the spreadsheet round-trip
WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.