Development

Flutter Internationalization: Complete i18n & l10n Guide 2026

Muhammad Shakil Muhammad Shakil
Apr 01, 2026
16 min read
Flutter internationalization and localization — multi-language app development guide
Back to Blog

I shipped my first Flutter app with hardcoded English strings everywhere. Login button? "Login". Error message? "Something went wrong". It worked fine — until a client in Saudi Arabia asked for Arabic support. That's when I spent three painful days ripping out every hardcoded string and retrofitting internationalization into an app that was never designed for it.

Don't make that mistake. Setting up i18n from the start takes about 30 minutes. Retrofitting it into an existing app takes days. This guide walks you through Flutter's official localization system — the one that uses ARB files and code generation — because it's the approach that scales, catches missing translations at compile time, and doesn't require any third-party packages beyond what Flutter already provides.

What You'll Learn

Prerequisites

Flutter 3.27+ and Dart 3.6+. Familiarity with Flutter project structure and basic state management concepts. No prior i18n experience needed.

1. Why Internationalization Matters for Flutter Apps

Here's a number that should get your attention: 75% of the world's population doesn't speak English. If your app only supports English, you're ignoring three-quarters of potential users. And the markets growing fastest in mobile — South Asia, the Middle East, Latin America, Southeast Asia — are predominantly non-English.

The Business Case

Apps that support local languages see 2-3x higher conversion rates in non-English markets. We saw this first-hand with a fintech app we built for a client targeting Pakistan and the UAE. Adding Urdu and Arabic support increased daily active users by 140% within the first month. Users simply trust apps more when they see their own language.

Beyond downloads, there's the App Store optimization angle. The Google Play Store indexes your app's metadata in every language you support. An app listing in Arabic shows up in Arabic keyword searches that your English-only competitor can't reach. Same with the Apple App Store.

i18n vs l10n — What's the Difference?

These terms get thrown around interchangeably, but they're distinct:

You do i18n once. You do l10n for every new language you add. Get the i18n architecture right and adding a new language is just dropping in a new ARB file.

2. Project Setup: flutter_localizations and intl

Flutter's localization system has two parts. The flutter_localizations package provides translated Material and Cupertino widget strings (button labels, date picker headers, dialog text). The intl package handles your custom app strings, plurals, and date/number formatting.

Adding Dependencies

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: ^0.19.0

dev_dependencies:
  intl_utils: ^2.8.0  # Optional: IDE integration for ARB files

Run flutter pub get and you're set. No native configuration needed — this works on iOS, Android, web, desktop, all platforms.

The l10n.yaml Configuration File

Create a file called l10n.yaml in your project root. This tells Flutter's code generator where to find your ARB files and what to generate:

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
synthetic-package: false
output-dir: lib/l10n/generated
nullable-getter: false

The key settings here: synthetic-package: false puts the generated code in your lib/ tree instead of a hidden .dart_tool folder, which makes it easier to inspect and debug. nullable-getter: false means AppLocalizations.of(context) returns a non-nullable value — no more null checks everywhere.

Configuring MaterialApp

Wire up the localization delegates in your MaterialApp:

import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/generated/app_localizations.dart';

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MyApp',
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      // Optional: force a specific locale for testing
      // locale: const Locale('ar'),
      home: const HomeScreen(),
    );
  }
}

That localizationsDelegates getter is auto-generated and includes GlobalMaterialLocalizations, GlobalWidgetsLocalizations, GlobalCupertinoLocalizations, and your own AppLocalizations delegate. You don't need to list them manually anymore.

3. ARB Files: Your Translation Database

ARB stands for Application Resource Bundle. It's a JSON-based format that the Dart team chose for Flutter localization. Each locale gets its own ARB file — app_en.arb for English, app_ar.arb for Arabic, app_ur.arb for Urdu.

The Template File (English)

Create lib/l10n/app_en.arb — this is your source of truth:

{
  "@@locale": "en",
  "appTitle": "MyApp",
  "@appTitle": {
    "description": "The application title shown in the app bar"
  },
  "welcomeMessage": "Welcome back, {userName}!",
  "@welcomeMessage": {
    "description": "Greeting shown on the home screen",
    "placeholders": {
      "userName": {
        "type": "String",
        "example": "Shakil"
      }
    }
  },
  "loginButton": "Sign In",
  "@loginButton": {
    "description": "Label for the sign-in button on the login screen"
  },
  "emailHint": "Enter your email address",
  "passwordHint": "Enter your password",
  "forgotPassword": "Forgot your password?",
  "noAccount": "Don't have an account? Sign up",
  "itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
  "@itemCount": {
    "description": "Shows the number of items in the cart",
    "placeholders": {
      "count": {
        "type": "int"
      }
    }
  }
}

Notice the pattern: every key has a corresponding @key metadata entry with a description. This is optional but worth doing — translators use these descriptions to understand context. Without them, a translator seeing "save" won't know if it means "save to disk" or "save money."

Adding a Translation (Arabic)

Create lib/l10n/app_ar.arb:

{
  "@@locale": "ar",
  "appTitle": "تطبيقي",
  "welcomeMessage": "!{userName} ،مرحباً بعودتك",
  "loginButton": "تسجيل الدخول",
  "emailHint": "أدخل بريدك الإلكتروني",
  "passwordHint": "أدخل كلمة المرور",
  "forgotPassword": "هل نسيت كلمة المرور؟",
  "noAccount": "ليس لديك حساب؟ سجل الآن",
  "itemCount": "{count, plural, =0{لا توجد عناصر} =1{عنصر واحد} =2{عنصران} few{{count} عناصر} many{{count} عنصرًا} other{{count} عنصر}}"
}

Arabic has six plural forms (zero, one, two, few, many, other) compared to English's two (one, other). The intl package handles this correctly based on CLDR plural rules. You don't need to know the rules — just provide the forms and Flutter picks the right one based on the count.

4. Code Generation with flutter gen-l10n

With your ARB files in place, run the code generator:

flutter gen-l10n

This creates a generated AppLocalizations class with type-safe getters for every string in your ARB files. If you misspell a key or forget a placeholder, you get a compile-time error instead of a runtime crash. That's the whole point of code generation.

What Gets Generated

The generator creates three files:

Each getter is strongly typed. A string with a placeholder like {userName} becomes a method that requires a String argument:

// Generated code — don't edit this
abstract class AppLocalizations {
  String get appTitle;
  String welcomeMessage(String userName);
  String get loginButton;
  String itemCount(int count);
  // ... more getters
}

If you add a new key to app_en.arb but forget to add it to app_ar.arb, the generator warns you. If you reference a key that doesn't exist, your IDE flags it immediately. No more runtime "key not found" errors.

Auto-Regeneration

Add generate: true to your pubspec.yaml to automatically regenerate localization code whenever you run flutter pub get or build:

# pubspec.yaml
flutter:
  generate: true

This way you don't need to manually run flutter gen-l10n after every ARB file change. The build system handles it.

5. Using Translations in Your Widgets

Access your translated strings through AppLocalizations.of(context):

import 'l10n/generated/app_localizations.dart';

class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context);

    return Scaffold(
      appBar: AppBar(title: Text(l10n.appTitle)),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(
              l10n.welcomeMessage('Shakil'),
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            const SizedBox(height: 32),
            TextField(
              decoration: InputDecoration(
                hintText: l10n.emailHint,
                prefixIcon: const Icon(Icons.email_outlined),
              ),
            ),
            const SizedBox(height: 16),
            TextField(
              obscureText: true,
              decoration: InputDecoration(
                hintText: l10n.passwordHint,
                prefixIcon: const Icon(Icons.lock_outline),
              ),
            ),
            const SizedBox(height: 8),
            Align(
              alignment: AlignmentDirectional.centerEnd,
              child: TextButton(
                onPressed: () {},
                child: Text(l10n.forgotPassword),
              ),
            ),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: () {},
              child: Text(l10n.loginButton),
            ),
          ],
        ),
      ),
    );
  }
}

I like creating a shorthand extension so you don't need to type AppLocalizations.of(context) every time:

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

// Usage: context.l10n.loginButton
// Instead of: AppLocalizations.of(context).loginButton

Shorter, cleaner, and every developer on the team knows exactly what context.l10n means.

6. Plurals, Gender, and Select Messages

This is where most i18n guides stop — and where the real complexity lives. English has simple plural rules: one item, multiple items. Other languages? Not so simple. Arabic has six plural forms. Polish has four. Russian has three, and the rules for which form to use are based on the last two digits of the number.

Plural Messages

The ICU message format handles all of this. You define the forms, Flutter picks the right one:

// In app_en.arb
"unreadMessages": "{count, plural, =0{No unread messages} =1{1 unread message} other{{count} unread messages}}",
"@unreadMessages": {
  "placeholders": {
    "count": { "type": "int" }
  }
}

// In app_ar.arb
"unreadMessages": "{count, plural, =0{لا توجد رسائل غير مقروءة} =1{رسالة واحدة غير مقروءة} =2{رسالتان غير مقروءتان} few{{count} رسائل غير مقروءة} many{{count} رسالة غير مقروءة} other{{count} رسالة غير مقروءة}}"

Gender-Specific Messages

Some languages conjugate differently based on gender. The select ICU format handles this:

"profileGreeting": "{gender, select, male{He updated his profile} female{She updated her profile} other{They updated their profile}}",
"@profileGreeting": {
  "placeholders": {
    "gender": { "type": "String" }
  }
}

Nested Placeholders

You can combine plural and string placeholders in a single message:

"cartSummary": "{userName} has {count, plural, =0{an empty cart} =1{1 item in cart} other{{count} items in cart}}",
"@cartSummary": {
  "placeholders": {
    "userName": { "type": "String" },
    "count": { "type": "int" }
  }
}

// Usage in Dart:
Text(l10n.cartSummary('Shakil', 3))
// Output: "Shakil has 3 items in cart"

7. Date and Number Formatting by Locale

Dates and numbers are the sneaky localization trap. 3/4/2026 is March 4th in the US and April 3rd in Europe. 1,000.50 is a thousand and a half in English but looks like 1.000,50 in German. Get these wrong and your finance app shows the wrong amounts.

Date Formatting

import 'package:intl/intl.dart';

// Locale-aware date formatting
final now = DateTime.now();
final locale = Localizations.localeOf(context).toString();

// Short date: 4/1/2026 (en_US) or ١/٤/٢٠٢٦ (ar)
Text(DateFormat.yMd(locale).format(now));

// Full date: April 1, 2026 (en) or ١ أبريل ٢٠٢٦ (ar)
Text(DateFormat.yMMMMd(locale).format(now));

// Day + time: Tuesday 3:45 PM (en) or الثلاثاء ٣:٤٥ م (ar)
Text(DateFormat.EEEE(locale).add_jm().format(now));

// Relative time (custom helper):
String timeAgo(DateTime date, String locale) {
  final diff = DateTime.now().difference(date);
  if (diff.inMinutes < 1) return 'Just now';
  if (diff.inHours < 1) return '${diff.inMinutes}m ago';
  if (diff.inDays < 1) return '${diff.inHours}h ago';
  return DateFormat.MMMd(locale).format(date);
}

Number and Currency Formatting

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

// Number: 1,234,567.89 (en) or ١٬٢٣٤٬٥٦٧٫٨٩ (ar)
Text(NumberFormat.decimalPattern(locale).format(1234567.89));

// Currency: $1,234.50 (en_US) or ١٬٢٣٤٫٥٠ ر.س (ar_SA)
Text(NumberFormat.currency(
  locale: locale,
  symbol: 'PKR ',
  decimalDigits: 0,
).format(50000));

// Compact number: 1.2M (en) or ١٫٢ مليون (ar)
Text(NumberFormat.compact(locale: locale).format(1200000));

// Percentage: 75% (en) or ٧٥٪ (ar)
Text(NumberFormat.percentPattern(locale).format(0.75));

Always use these formatters instead of string interpolation. "$price PKR" won't adapt to locale. NumberFormat.currency() will.

8. Right-to-Left (RTL) Language Support

If you're supporting Arabic, Urdu, Hebrew, or Persian, RTL layout is non-negotiable. The good news: Flutter handles most of it automatically. The bad news: "most of it" isn't "all of it."

What Flutter Handles Automatically

What You Need to Fix Manually

Replace all EdgeInsets with EdgeInsetsDirectional:

// BAD: Won't flip for RTL
padding: const EdgeInsets.only(left: 16, right: 8),

// GOOD: Flips automatically for RTL
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),

// BAD: Fixed alignment
alignment: Alignment.centerLeft,

// GOOD: Direction-aware alignment
alignment: AlignmentDirectional.centerStart,

// BAD: Fixed positioned
Positioned(left: 16, child: icon),

// GOOD: Direction-aware positioned
PositionedDirectional(start: 16, child: icon),

Icons That Should (and Shouldn't) Flip

Directional icons like back arrows, forward arrows, and list indicators should mirror in RTL. But logos, checkmarks, and non-directional icons should stay the same:

// Back arrow — should flip in RTL
Icon(
  Directionality.of(context) == TextDirection.rtl
    ? Icons.arrow_forward
    : Icons.arrow_back,
)

// Or use the directional variant:
const Icon(Icons.arrow_back) // Flutter auto-mirrors this in RTL

// Logo — should NOT flip
Directionality(
  textDirection: TextDirection.ltr,  // Force LTR for brand assets
  child: Image.asset('assets/logo.png'),
)

Testing RTL Without Changing System Language

Force RTL in your MaterialApp for testing without changing your phone's system language:

MaterialApp(
  locale: const Locale('ar'),  // Force Arabic locale
  // ... rest of config
)

9. Dynamic Locale Switching at Runtime

Users should be able to change the app language from settings without restarting. Here's a clean implementation using Riverpod and shared_preferences:

The Locale Provider

import 'dart:ui';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

final localeProvider = StateNotifierProvider<LocaleNotifier, Locale>((ref) {
  return LocaleNotifier();
});

class LocaleNotifier extends StateNotifier<Locale> {
  LocaleNotifier() : super(const Locale('en')) {
    _loadSavedLocale();
  }

  static const _key = 'app_locale';

  Future<void> _loadSavedLocale() async {
    final prefs = await SharedPreferences.getInstance();
    final saved = prefs.getString(_key);
    if (saved != null) {
      state = Locale(saved);
    } else {
      // Fall back to device locale if no preference saved
      final deviceLocale = PlatformDispatcher.instance.locale;
      state = _isSupportedLocale(deviceLocale)
          ? deviceLocale
          : const Locale('en');
    }
  }

  Future<void> setLocale(Locale locale) async {
    state = locale;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_key, locale.languageCode);
  }

  bool _isSupportedLocale(Locale locale) {
    return ['en', 'ar', 'ur', 'es', 'fr'].contains(locale.languageCode);
  }
}

Wiring It Into MaterialApp

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final locale = ref.watch(localeProvider);

    return MaterialApp(
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const HomeScreen(),
    );
  }
}

Language Picker Widget

class LanguagePicker extends ConsumerWidget {
  const LanguagePicker({super.key});

  static const _languages = [
    (locale: Locale('en'), name: 'English', flag: '🇺🇸'),
    (locale: Locale('ar'), name: 'العربية', flag: '🇸🇦'),
    (locale: Locale('ur'), name: 'اردو', flag: '🇵🇰'),
    (locale: Locale('es'), name: 'Español', flag: '🇪🇸'),
    (locale: Locale('fr'), name: 'Français', flag: '🇫🇷'),
  ];

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final currentLocale = ref.watch(localeProvider);

    return ListView.builder(
      shrinkWrap: true,
      itemCount: _languages.length,
      itemBuilder: (context, index) {
        final lang = _languages[index];
        final isSelected = currentLocale == lang.locale;

        return ListTile(
          leading: Text(lang.flag, style: const TextStyle(fontSize: 28)),
          title: Text(lang.name),
          trailing: isSelected ? const Icon(Icons.check, color: Colors.green) : null,
          selected: isSelected,
          onTap: () {
            ref.read(localeProvider.notifier).setLocale(lang.locale);
            Navigator.pop(context);
          },
        );
      },
    );
  }
}

When a user taps a language, the locale changes, MaterialApp rebuilds, and the entire widget tree re-renders with the new translations. Instant. No restart.

10. Testing Your Localized App

Don't ship localized apps without testing them. I've seen production apps where Arabic text overflowed containers because nobody tested RTL layouts, and Spanish translations that were truncated because buttons were sized for English text.

Widget Testing with Specific Locales

testWidgets('Login screen renders correctly in Arabic', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      locale: const Locale('ar'),
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const LoginScreen(),
    ),
  );
  await tester.pumpAndSettle();

  // Verify Arabic text is displayed
  expect(find.text('تسجيل الدخول'), findsOneWidget);

  // Verify RTL layout direction
  final directionality = tester.widget<Directionality>(
    find.byType(Directionality).first,
  );
  expect(directionality.textDirection, TextDirection.rtl);
});

Golden Tests for Visual Regression

Golden tests catch layout issues that string-matching tests miss. A button that looks fine in English might overflow in German (German words are 30% longer on average):

testWidgets('Login screen golden test - Arabic', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      locale: const Locale('ar'),
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const LoginScreen(),
    ),
  );
  await tester.pumpAndSettle();

  await expectLater(
    find.byType(LoginScreen),
    matchesGoldenFile('goldens/login_screen_ar.png'),
  );
});

Pseudo-Localization for Edge Cases

Before you have real translations, test with pseudo-localized strings. Replace every character with an accented version and add 30% more characters to simulate text expansion:

String pseudoLocalize(String text) {
  const charMap = {
    'a': 'à', 'b': 'ƀ', 'c': 'ç', 'd': 'đ', 'e': 'è',
    'f': 'ƒ', 'g': 'ĝ', 'h': 'ĥ', 'i': 'ì', 'j': 'ĵ',
    'k': 'ķ', 'l': 'ĺ', 'm': 'ɱ', 'n': 'ñ', 'o': 'ò',
    'p': 'þ', 'q': 'ǫ', 'r': 'ŕ', 's': 'ŝ', 't': 'ţ',
    'u': 'ù', 'v': 'ṽ', 'w': 'ŵ', 'x': 'ẋ', 'y': 'ý',
    'z': 'ž',
  };
  final pseudo = text.split('').map((c) =>
    charMap[c.toLowerCase()] ?? c
  ).join();
  // Add 30% padding to simulate text expansion
  final padding = '~' * (text.length * 0.3).ceil();
  return '[$pseudo$padding]';
}

// "Sign In" becomes "[Ŝìĝñ Ìñ~~]"

If your UI looks correct with pseudo-localized strings (nothing overflows, nothing truncates), it'll handle real translations fine.

11. Production Checklist

Before you ship a localized app, run through this list. I've seen each of these cause production bugs:

AspectFlutter Official (gen-l10n)easy_localizationslang
Setup complexityMediumLowLow
Type safetyFull (compile-time)None (runtime strings)Full (compile-time)
Plural/gender supportICU formatICU formatCustom syntax
File formatARB (JSON-based)JSON, YAML, CSVYAML, JSON
Code generationflutter gen-l10nNot neededBuild runner
Hot reload supportYesYesYes
RTL handlingAutomaticAutomaticAutomatic
Maintained byFlutter teamCommunityCommunity
RecommendationProduction appsPrototypes/MVPsDart-first teams

My recommendation: use the official flutter gen-l10n approach for any app that might grow beyond a prototype. The type safety alone saves debugging hours. easy_localization is fine for quick projects where you want to move fast and don't care about compile-time checks.

Related Guides

Frequently Asked Questions

What's the difference between i18n and l10n in Flutter?

Internationalization (i18n) is designing your app so it can adapt to different languages and regions without code changes. Localization (l10n) is the actual translation work — creating ARB files with translated strings for each locale. Think of i18n as building the framework (locale-aware widgets, message lookup) and l10n as filling it with content. Flutter's official tooling uses 'l10n' in its naming (flutter_localizations, l10n.yaml) but handles both.

Should I use the intl package or flutter_localizations?

Use both — they serve different purposes. flutter_localizations provides locale-aware Material and Cupertino widgets (date pickers, dialogs, text direction). The intl package handles message extraction, plurals, gender, and number/date formatting. Your app needs flutter_localizations for framework-level locale support and intl for your custom translated strings. Since Flutter 3.0, the recommended setup uses both with ARB files and code generation via flutter gen-l10n.

How many languages should I support at launch?

Start with 2-3 languages maximum. English plus your primary target market language is enough for launch. Adding more later is simple if your i18n architecture is solid — it's just adding new ARB files. The real work is getting the architecture right first: extracting all hardcoded strings, handling plurals, testing RTL layouts. Don't translate into 15 languages if your app hasn't validated product-market fit.

Can I change the app language without restarting?

Yes. Wrap your MaterialApp in a state management solution (Provider, Riverpod, BLoC) that holds the current locale. When the user picks a new language, update the locale value and MaterialApp rebuilds with the new translations. No app restart needed. The example in this guide shows a complete implementation using Riverpod that persists the user's language choice across sessions using shared_preferences.

How do I handle RTL languages like Arabic and Urdu?

Flutter handles RTL automatically when you set the locale correctly. The Directionality widget flips layouts, padding, and alignment. But you still need to test: icons with directional meaning (arrows) should flip, but logos shouldn't. Use TextDirection.rtl in tests, check that Row/Column children reorder correctly, and verify that EdgeInsetsDirectional (not EdgeInsets) is used throughout your app.

What's the best way to organize ARB files for a large app?

Keep one ARB file per locale in a dedicated lib/l10n/ directory. Name them app_en.arb, app_ar.arb, app_ur.arb. For very large apps (500+ strings), use prefixed keys: auth_loginButton, settings_languageLabel. Don't create separate ARB files per feature — the gen-l10n tool expects one file per locale. Use key prefixes and keep a consistent naming convention across all locales.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.