The Flutter Kit logoThe Flutter Kit
Guide

Flutter Localization Complete Guide 2026: l10n, intl, and Right-to-Left

The complete 2026 Flutter localization guide. gen-l10n, ARB files, ICU plurals, runtime language switching, right-to-left layouts, locale-aware formatting, AI translation in CI, and the patterns that actually ship.

Ahmed GaganAhmed Gagan
15 min read

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_localizations from the Flutter SDK provides Material, Cupertino, and Widgets localizations for 113 built-in locales. This is what makesshowDatePicker say "Mois" in French automatically.
  • intl handles ICU MessageFormat (plurals, gender, select), locale-aware date and number formatting via DateFormat andNumberFormat, and bidirectional text utilities.
  • flutter gen-l10n is the official CLI (built into the Flutter SDK since 3.10) that generates a typed AppLocalizations class from your ARB files. Replaces the older intl_translation workflow 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: true

Second, 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.centerStart instead of Alignment.centerLeft.
  • Use TextAlign.start and TextAlign.end instead ofTextAlign.left and TextAlign.right.
  • Mirror icons that imply direction with Transform(transform: Matrix4.rotationY(pi))wrapped in Directionality.of(context) == TextDirection.rtl. Built-in icons like Icons.arrow_back mirror automatically because Flutter shipsIcons.arrow_back_ios_new with matchTextDirection: 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:

Featuregen-l10n (official)easy_localizationslang
File formatARB (JSON + metadata)JSON, YAML, CSV, XMLJSON, YAML
Typed accessYes (generated class)No (string keys at runtime)Yes (deeply typed, nested)
ICU MessageFormatFullPlurals and gender onlyFull
Runtime locale switchManual (rebuild MaterialApp)Built in (context.setLocale)Manual (rebuild MaterialApp)
Hot reload of translationsRequires regenerationYesYes (build_runner watch)
MaintenanceFlutter teamCommunityCommunity (active)
Best forMost production appsPrototypes, simple appsLarge 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:

  1. Maintain only app_en.arb by hand. Every other ARB is generated.
  2. 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.
  3. The script writes the response back into the target ARB and adds a@@x-ai-translated marker per key.
  4. 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 default

Three 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 asonboarding_step_1_en.png and onboarding_step_1_es.png. Read the current locale via Localizations.localeOf(context) and build the asset path at runtime.
  • Audio onboarding: same pattern. Use aMap<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_inappwebview with 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 DateFormat and NumberFormat for each locale you support. Verify a known date, currency, and large number.
  • Widget tests that pump MaterialApp with a specificlocale and 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 left and right in padding and alignment.Use the directional equivalents everywhere.
  • Formatting dates with toString. Always useDateFormat with an explicit locale.
  • Forgetting initializeDateFormatting. Call it once at app startup or DateFormat returns garbage.
  • Skipping plural forms. 1 items looks broken. Use theplural ICU 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.

Share this article

Ready to ship your Flutter app faster?

The Flutter Kit gives you a production-ready Flutter codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Flutter Kit