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:
{
"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:
{
"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:
flutter:
generate: trueCreate a l10n.yaml file at your project root to configure the generator:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizationsRun 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:
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale('de'),
// ... other config
home: MyHomePage(),
)Inside any widget, you retrieve translations like this:
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:
extension L10nExt on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this)!;
}Then simplify widget code to:
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:
{
"userGreeting": "Hello, {name}!",
"orderSummary": "Your order is ready at {storeName}."
}The generated code creates methods with parameters:
String userGreeting(String name) {
return Intl.message(
'Hello, $name!',
name: 'userGreeting',
args: [name],
locale: _locale,
);
}And in your widget:
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:
{
"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:
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:
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:
dependencies:
intl: ^0.19.0Format a date in the user's locale:
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:
final formatter = NumberFormat('#,##0.##', locale);
print(formatter.format(1234.5)); // "1,234.5" (en_US) or "1.234,5" (de_DE)For currency:
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:
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
intlpackage—never hardcode locale-specific formatting logic. - Run
flutter gen-l10nafter every ARB file edit, and use CI checks to ensure no translation keys are missing across locales.






Comments
Loading comments…