Skip to content
ExponaBLOG
FLUTTER · DAY 070 / 100

Internationalization in Flutter: lessons from production

Multi-language apps with gen-l10n.

Expona Admin6 min read

Internationalization in Flutter: lessons from production

When your Flutter app needs to serve users across multiple countries and languages, localization isn't a feature you bolt on at the end—it's a decision that shapes your app's architecture from day one. We've learned this the hard way, deploying multi-language apps across e-commerce, ride-hailing, and SaaS platforms where a single missing translation or broken date format can tank user trust in a market. Internationalization in Flutter: lessons from production shares what we've discovered using Flutter's built-in gen-l10n system, ARB files, ICU message syntax, and the intl package.

ARB files—Messages per locale in JSON-like files

The foundation of Flutter localization is the Application Resource Bundle (ARB) format. Unlike scattered string constants or XML files, ARB gives you a single source of truth per locale, stored as JSON files in your lib/l10n directory.

Start with a base file for your primary language—usually English. Create app_en.arb:

dart
{
  "welcome": "Welcome to our app",
  "loginButton": "Sign in",
  "userGreeting": "Hello, {name}!",
  "itemCount": "{count, plural, =0{No items} one{One item} other{# items}}"
}

Then create locale-specific files. For German, you'd have app_de.arb:

dart
{
  "welcome": "Willkommen in unserer App",
  "loginButton": "Anmelden",
  "userGreeting": "Hallo, {name}!",
  "itemCount": "{count, plural, =0{Keine Artikel} one{Ein Artikel} other{# Artikel}}"
}

The structure mirrors your English file. Each key maps to translated text. Keys with placeholders or plural logic use ICU syntax—more on that in the next section.

Why ARB? It's language-agnostic, translators understand it, and it integrates seamlessly with Flutter's code generation pipeline. Tools like the gen-l10n tool process these files and emit strongly-typed Dart classes, eliminating string key typos that plague older localization approaches.

In your pubspec.yaml, declare that you're using localization:

dart
flutter:
  generate: true

Create a l10n.yaml file at your project root to configure the generator:

dart
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations

Run flutter gen-l10n and the build system generates your localization classes automatically.

Generated classes—AppLocalizations.of(context).welcome

After code generation, you access translations via a generated AppLocalizations class. This is where gen-l10n shines: type safety and autocomplete.

First, wrap your app with MaterialApp configuration:

dart
MaterialApp(
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  locale: Locale('de'),
  // ... other config
  home: MyHomePage(),
)

Inside any widget, you retrieve translations like this:

dart
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    
    return Scaffold(
      appBar: AppBar(title: Text(l10n.welcome)),
      body: Center(
        child: ElevatedButton(
          onPressed: () {},
          child: Text(l10n.loginButton),
        ),
      ),
    );
  }
}

Notice the null-coalescing operator: AppLocalizations.of(context)!. The build system guarantees a non-null value as long as your app has properly configured localization delegates.

This pattern beats string literals because the Dart analyzer catches typos at compile time. Refactoring a key name automatically updates every call site. And you get IDE autocomplete—a productivity win when you're managing hundreds of strings across dozens of locales.

For convenience, many teams create an extension on BuildContext:

dart
extension L10nExt on BuildContext {
  AppLocalizations get l10n => AppLocalizations.of(this)!;
}

Then simplify widget code to:

dart
Text(context.l10n.welcome)

Plurals and placeholders—ICU message syntax

Real apps rarely need static strings. Users have names, items have counts, and messages must adapt. ARB files use ICU Message Format for this.

Placeholders are the simplest case. In your ARB file:

dart
{
  "userGreeting": "Hello, {name}!",
  "orderSummary": "Your order is ready at {storeName}."
}

The generated code creates methods with parameters:

dart
String userGreeting(String name) {
  return Intl.message(
    'Hello, $name!',
    name: 'userGreeting',
    args: [name],
    locale: _locale,
  );
}

And in your widget:

dart
Text(context.l10n.userGreeting('Alice'))

Plurals are more involved. English has two plural forms (singular and other), but languages differ. Arabic has six. Russian has three. The ICU format handles this:

dart
{
  "cartItemCount": "{count, plural, =0{Your cart is empty} one{You have one item} other{You have # items}}"
}

The =0 case is exact; one and other handle grammatical plurals. The # symbol is replaced by the actual count. Generated code:

dart
String cartItemCount(int count) {
  return Intl.plural(
    count,
    zero: 'Your cart is empty',
    one: 'You have one item',
    other: 'You have $count items',
    locale: _locale,
  );
}

Usage:

dart
Text(context.l10n.cartItemCount(items.length))

You can combine plurals with named placeholders for complex sentences—for example, "Alice added 3 items to her cart." Consult the ICU documentation for the full syntax, but the pattern scales to most real-world messaging needs.

Formatting—intl package for dates, numbers and currency

Localization isn't just about language; it's about cultural conventions. Germans write dates as 31.12.2024, Americans as 12/31/2024. Numbers use different separators: 1.234,56 vs. 1,234.56. Currency symbols, time zones, and week start days vary.

The intl package provides locale-aware formatters. Add it to pubspec.yaml:

dart
dependencies:
  intl: ^0.19.0

Format a date in the user's locale:

dart
import 'package:intl/intl.dart';

final now = DateTime.now();
final locale = Localizations.localeOf(context).toString();
final formatter = DateFormat.yMMMd(locale); // "Dec 31, 2024"
print(formatter.format(now));

// Or use a predefined pattern
final shortDate = DateFormat.yMd(locale); // "12/31/24" (US) or "31.12.24" (DE)
print(shortDate.format(now));

For numbers:

dart
final formatter = NumberFormat('#,##0.##', locale);
print(formatter.format(1234.5)); // "1,234.5" (en_US) or "1.234,5" (de_DE)

For currency:

dart
final usdFormatter = NumberFormat.simpleCurrency(
  locale: locale,
  name: 'USD',
);
print(usdFormatter.format(99.99)); // "$99.99" (en_US) or "99,99 $" (de_DE)

A common pattern is creating a helper class that wraps intl formatters for your app's specific needs:

dart
class LocaleFormatter {
  final String locale;
  late final DateFormat _dateFormat = DateFormat.yMMMd(locale);
  late final NumberFormat _currencyFormat = 
      NumberFormat.simpleCurrency(locale: locale, name: 'USD');
  
  LocaleFormatter(this.locale);
  
  String formatDate(DateTime date) => _dateFormat.format(date);
  String formatPrice(double price) => _currencyFormat.format(price);
}

In your app, instantiate this based on the current locale and pass it through your state management layer (Provider, Riverpod, GetX, etc.). This keeps formatting logic centralized and testable.

Common mistakes

Hardcoding strings in UI code: It's tempting to skip ARB setup for small projects, but every hardcoded string becomes technical debt. Start with localization from day one, even if you only support one language initially.

Forgetting to run gen-l10n after editing ARB files: Changes to .arb files don't auto-regenerate the Dart classes. You must run flutter gen-l10n or use a build runner watch mode. IDEs can be configured to run this automatically on save.

Missing translations silently fall back to English: If a key exists in English but not in German, the German app shows English text. Bugs like this are easy to miss. Use integration tests or CI checks to verify all keys are present in all supported locales.

Date and number formatting without the intl package: Manually parsing or formatting dates/numbers based on assumptions about user locale is a minefield. Always use intl.

Not handling RTL languages: Arabic, Hebrew, and Persian require right-to-left text direction. Enable it in your Material app configuration and test thoroughly. Flutter handles most of the heavy lifting, but custom layouts can break.

Key takeaways

  • ARB files are the standard format for Flutter localization—one file per locale in JSON-like syntax, processed by gen-l10n to generate strongly-typed Dart classes.
  • Access translations via AppLocalizations.of(context), which provides compile-time safety and IDE autocomplete, eliminating string key typos.
  • Use ICU message syntax for plurals and placeholders: {count, plural, =0{...} one{...} other{...}} handles grammatical variations across languages.
  • Delegate date, number, and currency formatting to the intl package—never hardcode locale-specific formatting logic.
  • Run flutter gen-l10n after every ARB file edit, and use CI checks to ensure no translation keys are missing across locales.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles