← All posts

6 min read

Flutter Localization in Practice: ARB Files, Plurals, RTL and Runtime Switching

Flutter localization in practice with gen-l10n: ARB files, placeholders, ICU plurals and select, dates and numbers with intl, RTL layouts and runtime locale switching.

  • Flutter
  • Localization
  • i18n
Cover illustration for Flutter Localization in Practice: ARB Files, Plurals, RTL and Runtime Switching

Adding a second language to an app is never about the second language. It’s about discovering the hundreds of hardcoded strings, the date formatted as MM/dd/yyyy in a helper nobody remembers writing, and the button whose label fits in English and nowhere else.

Flutter’s built-in tooling handles the mechanics well. The work is in the habits around it. Here’s how I set up localization so that adding a language later is a translation task, not a refactor.

Setup: gen-l10n and ARB files

Flutter’s gen-l10n tool reads ARB files (JSON with metadata) and generates a typed AppLocalizations class. No magic string keys at runtime, and a missing message is a compile error.

Add the dependencies in pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: any

flutter:
  generate: true

Then add l10n.yaml at the project root:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false

nullable-getter: false makes AppLocalizations.of(context) return a non-null value, which saves a ! on every call. On current Flutter versions the generated files are written into your source tree (the old synthetic flutter_gen package is gone), so you import them from your own package.

Your template file, lib/l10n/app_en.arb:

{
  "@@locale": "en",
  "appTitle": "Schedule",
  "@appTitle": { "description": "App name shown in the app bar" },
  "signIn": "Sign in",
  "@signIn": { "description": "Button on the login screen" }
}

Those description fields aren’t optional decoration. Translators see “Schedule” without context and have to guess whether it’s a noun or a verb. Many languages use completely different words for each.

Wiring it into the app

MaterialApp.router(
  routerConfig: router,
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
);

AppLocalizations.localizationsDelegates already includes the Material, Cupertino and widgets delegates from flutter_localizations, so built-in widgets like date pickers and text field context menus are translated too.

Using a string:

final l10n = AppLocalizations.of(context);
FilledButton(onPressed: _submit, child: Text(l10n.signIn));

Placeholders, plurals and select

String concatenation is the classic localization bug. "Hello, " + name assumes the name comes last, which isn’t true in every language. Use placeholders:

{
  "greeting": "Hello, {name}",
  "@greeting": {
    "placeholders": { "name": { "type": "String" } }
  }
}

Plurals use ICU syntax. Don’t write count == 1 ? 'item' : 'items' in Dart, because some languages have more than two plural forms:

{
  "shiftCount": "{count, plural, =0{No shifts} =1{1 shift} other{{count} shifts}}",
  "@shiftCount": {
    "placeholders": { "count": { "type": "int" } }
  }
}

Each translation can define the categories its language needs (zero, one, two, few, many, other), and intl picks the right one.

select handles variations on a string value, such as grammatical gender or a status:

{
  "orderStatus": "{status, select, pending{Waiting for payment} shipped{On its way} other{Processing}}",
  "@orderStatus": {
    "placeholders": { "status": { "type": "String" } }
  }
}

The generated methods are typed: l10n.shiftCount(3), l10n.orderStatus('shipped').

Dates, numbers and currency

Dates and numbers are localization too. The intl package formats them per locale:

final locale = Localizations.localeOf(context).toString();

final date = DateFormat.yMMMd(locale).format(shift.startsAt);
final time = DateFormat.jm(locale).format(shift.startsAt);
final price = NumberFormat.simpleCurrency(locale: locale).format(19.99);

yMMMd is a skeleton, not a fixed pattern, so it becomes “Jan 7, 2026” in US English and “7 janv. 2026” in French. You can also format directly in ARB with a DateTime placeholder and a "format": "yMMMd" entry.

One caution on currency: simpleCurrency(locale:) picks the currency from the locale. If your prices are in a specific currency regardless of where the user is, pass the currency explicitly with NumberFormat.simpleCurrency(locale: locale, name: 'USD'). A German user paying in dollars should see dollars formatted the German way, not euros.

RTL layouts

When you add Arabic, Hebrew, Persian or Urdu, MaterialApp flips the ambient Directionality to right-to-left automatically. Rows reverse, app bars mirror, and back arrows point the other way. Your own layout code only follows along if you write it direction-aware:

Padding(
  padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
  child: Align(
    alignment: AlignmentDirectional.centerStart,
    child: Text(l10n.greeting(user.name)),
  ),
);

The rule: think start/end, not left/right. Use EdgeInsetsDirectional, AlignmentDirectional, PositionedDirectional and BorderRadiusDirectional. Icons that imply direction (arrows, “next”) should mirror; icons that don’t (a checkmark, a play button in most designs) should not. You can test RTL without translations by wrapping a screen in Directionality(textDirection: TextDirection.rtl, child: ...).

Switching locale at runtime

By default the app follows the device language. Many users want to override that in settings. Pass a locale to MaterialApp from your state management:

class LocaleController extends ValueNotifier<Locale?> {
  LocaleController() : super(null); // null = follow the system
}

ValueListenableBuilder<Locale?>(
  valueListenable: localeController,
  builder: (context, locale, _) => MaterialApp.router(
    locale: locale,
    localizationsDelegates: AppLocalizations.localizationsDelegates,
    supportedLocales: AppLocalizations.supportedLocales,
    routerConfig: router,
  ),
);

Persist the choice (a language code in SharedPreferences is fine) and always offer a “System default” option. Also send the locale to your backend when it generates text, such as emails or push notifications, so those match the app. On Android 13+ and iOS there are also per-app language settings at the OS level, which is worth knowing about when users report “the app ignores my language.”

A translation workflow that survives

The technical setup is the easy part. What keeps localization healthy:

  • English ARB is the source of truth. Developers only ever add keys there.
  • Translations come back as ARB files from a translation platform (most support ARB directly) or a translator. Don’t hand-edit translated files in code review.
  • Use untranslated-messages-file in l10n.yaml to get a list of keys missing per locale, and check it in CI.
  • Never reuse a key because the English happens to match. “Open” as in “open the file” and “Open” as in “the store is open” are different strings in most languages.

Test with pseudo-localization and long strings

You don’t need real translations to find layout bugs. A pseudo-locale where every string is accented and padded, such as [Śîĝñ îñ ~~~~~~~~], shows you two things at once: any text that isn’t accented is hardcoded, and anything that overflows will overflow in German or Finnish too. I generate it with a small script that transforms app_en.arb into an extra ARB file used only in debug builds.

Combine that with large system text sizes and small phones, and you’ll catch most overflow issues before a translator ever sees the app. The same flexible-layout habits help with responsive phone and tablet layouts and with accessibility, because a layout that survives long German strings usually survives 200% text scaling as well.

Don’t forget the store listing

Users decide whether to install before they see your in-app translations. App Store Connect and Google Play Console both support localized titles, descriptions, keywords (on iOS) and screenshots per language. Localized screenshots, captured from the app running in that locale, make a noticeably better impression than English screenshots with a translated caption. I keep store copy alongside the release process covered in my App Store and Google Play release checklist.

Takeaways

  • Use gen-l10n with ARB files for typed, compile-checked strings, and give every key a description.
  • Never concatenate strings; use placeholders, ICU plurals and select.
  • Format dates, numbers and currency with intl using the current locale.
  • Write layouts with start/end and the Directional widgets so RTL works for free.
  • Test with a pseudo-locale and long strings before paying for real translations.
  • Localize the store listing, not just the app.

Localization done early costs a little discipline per string; done late, it costs a sprint.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).