Flutter internationalization

Flutter i18n with ARB files and gen_l10n: setup, ICU plurals and select, placeholders with formatting, locale resolution and keeping ARB files in sync.

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

yaml
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: any

flutter:
  generate: true
yaml
# 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:

bash
flutter gen-l10n

Wiring it up

dart
import "package:flutter_gen/gen_l10n/app_localizations.dart";

MaterialApp(
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  home: const HomePage(),
);
dart
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

json
{
  "@@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

json
{
  "messageCount": "{count, plural, =0{No messages} one{1 message} other{{count} messages}}",
  "@messageCount": {
    "placeholders": {
      "count": { "type": "int" }
    }
  }
}
dart
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

json
{
  "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:

json
{
  "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:

yaml
# 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:

dart
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

  • supportedLocales not updated when a language is added, so the ARB exists and is never selected.
  • Plurals used for languages whose ARB only has one and other, 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.plist and 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:

dart
// ✗ 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:

dart
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:

bash
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

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.