Localization is the single highest-leverage growth move most indie Flutter teams skip. Adding even five languages typically doubles your addressable market and lifts install conversion by 15 to 30 percent on the App Store, where Apple ranks search results per locale. The mechanics are not hard in 2026, but the defaults changed: gen-l10n is now the official Flutter path, ICU MessageFormat handles plurals and gender properly, and right-to-left layouts work out of the box if you stop hard-coding EdgeInsets.only(left: ...). This guide is the end-to-end 2026 setup I now use on every new project.
Short version: enable flutter_localizations and intl, drop anl10n.yaml in your project root, write your strings as ARB files inlib/l10n/, and let flutter gen-l10n generate a typedAppLocalizations class. Switch language at runtime by rebuildingMaterialApp with a new locale. Mirror your layout for RTL by replacing every left and right with start andend. That is 80 percent of localization in Flutter.
The 2026 Flutter localization stack
Three packages do the heavy lifting and they all ship from the Flutter team:
flutter_localizationsfrom the Flutter SDK provides Material, Cupertino, and Widgets localizations for 113 built-in locales. This is what makesshowDatePickersay "Mois" in French automatically.intlhandles ICU MessageFormat (plurals, gender, select), locale-aware date and number formatting viaDateFormatandNumberFormat, and bidirectional text utilities.flutter gen-l10nis the official CLI (built into the Flutter SDK since 3.10) that generates a typedAppLocalizationsclass from your ARB files. Replaces the olderintl_translationworkflow that nobody enjoyed.
You do not need easy_localization or slang to ship a real product in 2026. They have their place (covered below), but the official stack is now polished enough that most teams should start there and only switch if they hit a specific limit.
Setup: l10n.yaml, ARB files, generated AppLocalizations
Enable the official toolchain in three files. First, pubspec.yaml:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.20.0
flutter:
generate: true # critical, enables gen-l10n
uses-material-design: trueSecond, l10n.yaml at the project root tells the generator where to find ARB files and where to write the generated class:
# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false
synthetic-package: false
output-dir: lib/l10n/generated
preferred-supported-locales: ["en", "es", "fr", "de", "ar", "ja"]Third, the template ARB file. ARB (Application Resource Bundle) is JSON with optional metadata attributes prefixed with @:
// lib/l10n/app_en.arb
{
"@@locale": "en",
"appTitle": "Flare",
"@appTitle": {
"description": "The application title shown in the title bar"
},
"welcomeUser": "Welcome back, {name}!",
"@welcomeUser": {
"description": "Greeting shown on home screen",
"placeholders": {
"name": { "type": "String", "example": "Ada" }
}
},
"unreadMessages": "{count, plural, =0{No new messages} =1{1 new message} other{{count} new messages}}",
"@unreadMessages": {
"description": "Inbox unread count",
"placeholders": {
"count": { "type": "int", "format": "compact" }
}
},
"price": "{amount, number, currency}",
"@price": {
"placeholders": {
"amount": { "type": "double" }
}
}
}Add a Spanish translation as app_es.arb:
// lib/l10n/app_es.arb
{
"@@locale": "es",
"appTitle": "Flare",
"welcomeUser": "Bienvenido de nuevo, {name}!",
"unreadMessages": "{count, plural, =0{Sin mensajes nuevos} =1{1 mensaje nuevo} other{{count} mensajes nuevos}}",
"price": "{amount, number, currency}"
}Run flutter gen-l10n (or just flutter pub get ifgenerate: true is enabled, which auto-runs the generator). You now haveAppLocalizations available everywhere:
// lib/main.dart
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/generated/app_localizations.dart';
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomeScreen(),
);
// In a widget:
final l10n = AppLocalizations.of(context);
Text(l10n.welcomeUser('Ada')),
Text(l10n.unreadMessages(7)),Switching language at runtime without restart
Apps that force a restart on language change feel broken in 2026. The fix is one line: pass a locale prop to MaterialApp that you control with a provider. Riverpod example:
// lib/locale/locale_provider.dart
final localeProvider = StateNotifierProvider<LocaleNotifier, Locale?>(
(ref) => LocaleNotifier(),
);
class LocaleNotifier extends StateNotifier<Locale?> {
LocaleNotifier() : super(null) {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final code = prefs.getString('locale');
if (code != null) state = Locale(code);
}
Future<void> set(Locale? locale) async {
state = locale; // null means follow device
final prefs = await SharedPreferences.getInstance();
if (locale == null) {
await prefs.remove('locale');
} else {
await prefs.setString('locale', locale.languageCode);
}
}
}
// In your root widget:
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = ref.watch(localeProvider);
return MaterialApp(
locale: locale, // null lets Flutter pick from system
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomeScreen(),
);
}
}BLoC users follow the same pattern with a LocaleCubit<Locale?>. Thenull sentinel is important: it tells Flutter to use the device locale, which is what most users actually want.
Pluralization, gender, and ICU MessageFormat
English has two plural forms (one, other). Arabic has six (zero, one, two, few, many, other). Russian has four. Polish has three. The plural ICU clause solves this without you ever having to know the rules:
// app_en.arb
"itemsInCart": "{count, plural, =0{Your cart is empty} =1{1 item in cart} other{{count} items in cart}}"
// app_ar.arb (Arabic uses zero/one/two/few/many/other)
"itemsInCart": "{count, plural, zero{سلتك فارغة} one{منتج واحد في السلة} two{منتجان في السلة} few{{count} منتجات في السلة} many{{count} منتجًا في السلة} other{{count} منتج في السلة}}"
// app_ru.arb (Russian uses one/few/many/other)
"itemsInCart": "{count, plural, one{{count} товар в корзине} few{{count} товара в корзине} many{{count} товаров в корзине} other{{count} товара в корзине}}"The select clause handles gender and other categorical variants. Useful for languages like Spanish where adjectives agree with gender:
"greeting": "{gender, select, male{Bienvenido} female{Bienvenida} other{Te damos la bienvenida}} {name}"
// Usage:
l10n.greeting('female', 'Maria'); // "Bienvenida Maria"Two rules. First, never concatenate strings to form sentences. "You have " + count + " messages"breaks in every language with non-Indo-European grammar. Always pass parameters into a single message. Second, your translators need the description andexample metadata. Without them, an Arabic translator does not know if{name} is a person, a product, or a place, and they will translate it wrong.
Right-to-left (RTL) layout for Arabic and Hebrew
Flutter has full RTL support since 1.0 but the defaults only work if you wrote your widgets correctly. The rules:
- Use
EdgeInsetsDirectional.only(start: 16, end: 8)instead ofEdgeInsets.only(left: 16, right: 8). - Use
AlignmentDirectional.centerStartinstead ofAlignment.centerLeft. - Use
TextAlign.startandTextAlign.endinstead ofTextAlign.leftandTextAlign.right. - Mirror icons that imply direction with
Transform(transform: Matrix4.rotationY(pi))wrapped inDirectionality.of(context) == TextDirection.rtl. Built-in icons likeIcons.arrow_backmirror automatically because Flutter shipsIcons.arrow_back_ios_newwithmatchTextDirection: true.
A reusable widget that mirrors an asset icon in RTL:
class DirectionalIcon extends StatelessWidget {
const DirectionalIcon(this.icon, {super.key, this.size = 24, this.color});
final IconData icon;
final double size;
final Color? color;
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
final iconWidget = Icon(icon, size: size, color: color);
return isRtl
? Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..scale(-1.0, 1.0, 1.0),
child: iconWidget,
)
: iconWidget;
}
}Test RTL by adding Arabic to your supported locales and switching the device locale, or force it in dev with MaterialApp(locale: const Locale('ar')). Common bug: a custom ListTile implementation that hard-codes a leading-side Padding with EdgeInsets.only(left:). The trailing icon ends up overlapping the title in RTL. Fix is one find-and-replace.
Locale-aware date, number, and currency formatting
Never format dates or numbers yourself. The intl package handles every locale correctly, including Arabic-Indic numerals, Japanese era dates, and Indian number grouping (1,00,000 not 100,000):
import 'package:intl/intl.dart';
final locale = Localizations.localeOf(context).toString();
// Dates
DateFormat.yMMMd(locale).format(DateTime.now()); // "May 17, 2026" or "17 mai 2026"
DateFormat.Hm(locale).format(DateTime.now()); // "14:30" or "2:30 PM"
// Numbers
NumberFormat.decimalPattern(locale).format(1234567.89);
// en_US: "1,234,567.89"
// de_DE: "1.234.567,89"
// hi_IN: "12,34,567.89"
// Currency
NumberFormat.simpleCurrency(locale: locale, name: 'USD').format(49.99);
// en_US: "$49.99", de_DE: "49,99 $", ja_JP: "$49.99"
// Relative time
import 'package:intl/intl.dart';
import 'package:intl/locale.dart';
// Use the timeago package for "3 hours ago" style relative formatting.Two gotchas. First, currency symbols are not interchangeable across locales. Always pass the explicit name: 'USD' instead of letting the formatter pick the default for the locale, otherwise a French user sees euros for an app priced in dollars. Second, initialize date formatting before first use:await initializeDateFormatting(locale). Forgetting this is the most common reason DateFormat returns garbage on launch.
Handling fallback locales and missing translations
You will not translate every string into every language on day one. The right fallback chain is: requested locale, then the language family (es-MX falls back to es), then your template locale (en). gen-l10n handles this automatically when you implementlocaleResolutionCallback:
MaterialApp(
supportedLocales: AppLocalizations.supportedLocales,
localeResolutionCallback: (device, supported) {
if (device == null) return supported.first;
// Exact match
for (final s in supported) {
if (s.languageCode == device.languageCode &&
s.countryCode == device.countryCode) return s;
}
// Language match
for (final s in supported) {
if (s.languageCode == device.languageCode) return s;
}
// Fallback to template
return supported.first;
},
localizationsDelegates: AppLocalizations.localizationsDelegates,
);For partial translations within a single language, gen-l10n is strict by default: every key in your template ARB must exist in every other ARB or generation fails. Loosen this withuntranslated-messages-file: missing_translations.txt inl10n.yaml, which logs missing keys to a file but still falls back to the template string at runtime. Use this during translation work, then re-enable strictness before release.
easy_localization vs gen-l10n vs slang
The three real options in 2026, side by side:
| Feature | gen-l10n (official) | easy_localization | slang |
|---|---|---|---|
| File format | ARB (JSON + metadata) | JSON, YAML, CSV, XML | JSON, YAML |
| Typed access | Yes (generated class) | No (string keys at runtime) | Yes (deeply typed, nested) |
| ICU MessageFormat | Full | Plurals and gender only | Full |
| Runtime locale switch | Manual (rebuild MaterialApp) | Built in (context.setLocale) | Manual (rebuild MaterialApp) |
| Hot reload of translations | Requires regeneration | Yes | Yes (build_runner watch) |
| Maintenance | Flutter team | Community | Community (active) |
| Best for | Most production apps | Prototypes, simple apps | Large apps with deep namespaces |
My 2026 recommendation: start with gen-l10n. If you outgrow it because you want deeply nested namespaces (t.profile.settings.privacy.title) or YAML files,slang is the upgrade. easy_localization is great for prototypes but its string-key access ('hello.world'.tr()) loses compile-time safety, which costs you on a serious codebase.
AI-assisted translation: GPT-4o / Gemini in your CI
In 2026, AI translation is finally good enough that I ship machine-translated strings as a first pass for non-critical UI, then mark them for human review before the next release. The pattern I use:
- Maintain only
app_en.arbby hand. Every other ARB is generated. - A CI script (Python or Dart) reads
app_en.arb, computes the diff against the existing target ARBs, and sends new or changed keys to GPT-4o or Gemini 2.5 Flash with the description metadata as context. - The script writes the response back into the target ARB and adds a
@@x-ai-translatedmarker per key. - A native speaker reviews the AI-translated keys before release. Once approved, the marker is removed.
A minimal Dart driver for the CI step:
// scripts/translate.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
Future<String> translate(String src, String targetLang, String description) async {
final res = await http.post(
Uri.parse('https://api.openai.com/v1/chat/completions'),
headers: {
'Authorization': 'Bearer ' + Platform.environment['OPENAI_KEY']!,
'Content-Type': 'application/json',
},
body: jsonEncode({
'model': 'gpt-4o-mini',
'messages': [
{
'role': 'system',
'content': 'Translate to ' + targetLang + '. Preserve ICU MessageFormat placeholders exactly. Context: ' + description,
},
{'role': 'user', 'content': src},
],
}),
);
return jsonDecode(res.body)['choices'][0]['message']['content'];
}Two warnings. Always preserve ICU placeholders verbatim. Models will sometimes localize{count} into {cantidad} in Spanish, which breaks everything. Add an explicit instruction to keep placeholders, and validate the output before writing. Second, never AI-translate legal copy, privacy policy, or paywall pricing strings. Pay a human translator for those.
Storing user preferences with shared_preferences
Persist the user's explicit language choice with shared_preferences (orHive if you already use it for other state):
// Save
final prefs = await SharedPreferences.getInstance();
await prefs.setString('locale', 'es');
// Load on startup
final code = prefs.getString('locale');
final locale = code != null ? Locale(code) : null; // null = device defaultThree rules. Default to the device locale (pass null) on first launch. Save only when the user explicitly picks a language in settings. Provide a "Use device language" option that clears the saved value, otherwise users who change their phone language wonder why your app does not follow.
Localizing assets: images, audio, legal docs
Strings are not the only thing that needs translation. The patterns:
- Images with text: store locale-suffixed copies as
onboarding_step_1_en.pngandonboarding_step_1_es.png. Read the current locale viaLocalizations.localeOf(context)and build the asset path at runtime. - Audio onboarding: same pattern. Use a
Map<String, String>of locale code to asset path. - Legal documents (privacy policy, terms of service): host them on your website with locale-prefixed URLs like
/legal/en/privacy. Open them in aflutter_inappwebviewwith the user's locale. Do not bundle legal copy inside the app binary, you will want to update it without shipping a new release. - App Store and Play Store listings: each store has its own localization UI. Translate your screenshots, description, keywords, and what's-new copy for every locale you support. This is where you actually capture the install conversion lift.
Testing localized UIs with golden tests
Three test types cover localization:
- Unit tests on
DateFormatandNumberFormatfor each locale you support. Verify a known date, currency, and large number. - Widget tests that pump
MaterialAppwith a specificlocaleand assert localized text appears. - Golden tests per locale for screens with substantial text. Catches layout overflow in German (long compound words), text clipping in Arabic (different baselines), and broken alignment in RTL.
testGoldens('home screen in Arabic', (tester) async {
await tester.pumpWidgetBuilder(
const HomeScreen(),
wrapper: materialAppWrapper(
locale: const Locale('ar'),
localizations: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
),
surfaceSize: const Size(390, 844),
);
await screenMatchesGolden(tester, 'home_screen_ar');
});Run goldens across English, German (longest text), and Arabic (RTL) at minimum. Those three catch 90 percent of layout regressions.
Common mistakes to avoid
- Concatenating strings to form sentences. Always pass parameters into a single ICU message.
- Hard-coded
leftandrightin padding and alignment.Use the directional equivalents everywhere. - Formatting dates with
toString. Always useDateFormatwith an explicit locale. - Forgetting
initializeDateFormatting. Call it once at app startup orDateFormatreturns garbage. - Skipping plural forms.
1 itemslooks broken. Use thepluralICU clause even if you only ship in English. - AI-translating without human review. Machine output is good but not perfect. A native speaker will catch idioms, register, and cultural fit.
- Bundling legal docs in the app binary. Host them on your website so you can update them without a release.
- No fallback chain. A user with es-MX should see Spanish, not English, even if you only translated to es-ES.
What The Flutter Kit ships
The Flutter Kit ships with a fully-configured localization stack:flutter_localizations plus intl wired into MaterialApp, an l10n.yaml tuned for the official gen-l10n workflow, ARB templates for six starter locales (English, Spanish, French, German, Arabic, Japanese), aLocaleNotifier via Riverpod for runtime language switching withshared_preferences persistence, RTL-tested screens for paywall, onboarding, and settings, a CI script that calls GPT-4o to first-pass new keys, and golden tests for English, German, and Arabic to catch layout regressions before release.
$69 one-time, unlimited commercial projects. See every integration on the features page or jump straight to checkout.
Final recommendation
If you are starting a new Flutter app in 2026, use gen-l10n with ARB files and the official stack. It is the path the Flutter team supports, the IDE understands it, and you keep compile-time safety. Start with English plus your two largest non-English markets, ship golden tests for German and Arabic, automate a first-pass translation step in CI, and pay a human translator for legal and paywall copy. Localization done right is one of the few remaining cheap growth levers on the App Store and Play Store in 2026. Skip it and you leave half your TAM on the table.