Building a Flutter app that reaches users worldwide requires proper internationalization (i18n). While Flutter provides excellent i18n support out of the box, understanding how to effectively use ARB (Application Resource Bundle) files can be the difference between a clunky localization workflow and a streamlined, maintainable system. This comprehensive tutorial walks you through everything you need to know about internationalizing your Flutter app with ARB files, from basic setup to advanced patterns and modern tooling with IntlPull.
What Are ARB Files and Why Use Them?
ARB (Application Resource Bundle) is a JSON-based format specifically designed for storing localizable resources. Originally created by Google, ARB files have become the standard for Flutter internationalization because they offer several key advantages:
Type Safety: ARB files work with Flutter's code generation to create type-safe translation methods, catching errors at compile time rather than runtime.
Rich Metadata: Unlike plain JSON, ARB files support metadata for each translation, including descriptions, placeholders, and examples that help translators understand context.
ICU MessageFormat Support: Built-in support for complex grammar rules like plurals, gender forms, and conditional text.
Tooling Integration: ARB files integrate seamlessly with translation management systems, making team collaboration effortless.
Here's what a basic ARB file looks like:
JSON1{ 2 "@@locale": "en", 3 "helloWorld": "Hello World!", 4 "@helloWorld": { 5 "description": "The conventional newborn programmer greeting" 6 } 7}
The @@locale key identifies the language, helloWorld is your translation key, and @helloWorld contains metadata that helps translators understand the context.
Setting Up Flutter Internationalization
Let's build a complete internationalization setup from scratch. We'll create a simple app that demonstrates all the key concepts.
Step 1: Add Required Dependencies
First, update your pubspec.yaml to include the necessary packages:
YAML1dependencies: 2 flutter: 3 sdk: flutter 4 flutter_localizations: 5 sdk: flutter 6 intl: ^0.19.0 7 8flutter: 9 generate: true
The flutter_localizations package provides localization support for Material and Cupertino widgets, while intl handles message formatting, date/time formatting, and number formatting.
Step 2: Configure Code Generation
Create a file named l10n.yaml in your project root:
YAMLarb-dir: lib/l10n template-arb-file: app_en.arb output-localization-file: app_localizations.dart
This configuration tells Flutter where to find your ARB files and where to generate the localization code.
Step 3: Create Your First ARB File
Create the directory lib/l10n and add your template file app_en.arb:
JSON1{ 2 "@@locale": "en", 3 "appTitle": "My Awesome App", 4 "@appTitle": { 5 "description": "The title of the application" 6 }, 7 "welcomeMessage": "Welcome to our app!", 8 "@welcomeMessage": { 9 "description": "Message shown on the home screen" 10 }, 11 "greeting": "Hello, {name}!", 12 "@greeting": { 13 "description": "Personalized greeting", 14 "placeholders": { 15 "name": { 16 "type": "String", 17 "example": "Alice" 18 } 19 } 20 } 21}
Step 4: Add Additional Languages
Create app_es.arb for Spanish:
JSON1{ 2 "@@locale": "es", 3 "appTitle": "Mi Aplicación Increíble", 4 "welcomeMessage": "¡Bienvenido a nuestra aplicación!", 5 "greeting": "¡Hola, {name}!" 6}
Notice that you only need to include the translations, not the metadata. The metadata from the template file is automatically used.
Step 5: Generate Localization Code
Run the code generation command:
Terminalflutter gen-l10n
This creates type-safe Dart classes in .dart_tool/flutter_gen/gen_l10n/ that you'll use to access your translations.
Step 6: Configure Your App
Update your main.dart to enable localization:
DART1import 'package:flutter/material.dart'; 2import 'package:flutter_localizations/flutter_localizations.dart'; 3import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 4 5void main() { 6 runApp(const MyApp()); 7} 8 9class MyApp extends StatelessWidget { 10 const MyApp({super.key}); 11 12 13 Widget build(BuildContext context) { 14 return MaterialApp( 15 title: 'Flutter i18n Demo', 16 localizationsDelegates: const [ 17 AppLocalizations.delegate, 18 GlobalMaterialLocalizations.delegate, 19 GlobalWidgetsLocalizations.delegate, 20 GlobalCupertinoLocalizations.delegate, 21 ], 22 supportedLocales: const [ 23 Locale('en'), 24 Locale('es'), 25 ], 26 home: const HomeScreen(), 27 ); 28 } 29}
Step 7: Use Translations in Your Widgets
Now you can access translations in any widget:
DART1class HomeScreen extends StatelessWidget { 2 const HomeScreen({super.key}); 3 4 5 Widget build(BuildContext context) { 6 final l10n = AppLocalizations.of(context)!; 7 8 return Scaffold( 9 appBar: AppBar( 10 title: Text(l10n.appTitle), 11 ), 12 body: Center( 13 child: Column( 14 mainAxisAlignment: MainAxisAlignment.center, 15 children: [ 16 Text( 17 l10n.welcomeMessage, 18 style: Theme.of(context).textTheme.headlineMedium, 19 ), 20 const SizedBox(height: 20), 21 Text(l10n.greeting('Alice')), 22 ], 23 ), 24 ), 25 ); 26 } 27}
That's it! Your app now supports multiple languages. The device's locale will automatically determine which language to display.
Handling Plurals and Gender Forms
One of the most powerful features of ARB files is support for ICU MessageFormat, which handles complex grammar rules that vary across languages.
Plural Forms
Different languages have different plural rules. English has two forms (one/other), but Polish has three, and Arabic has six. ARB files handle this automatically:
JSON1{ 2 "itemCount": "{count, plural, =0{No items} =1{One item} other{{count} items}}", 3 "@itemCount": { 4 "description": "Number of items in the cart", 5 "placeholders": { 6 "count": { 7 "type": "int" 8 } 9 } 10 } 11}
In Spanish (app_es.arb):
JSON{ "itemCount": "{count, plural, =0{Sin artículos} =1{Un artículo} other{{count} artículos}}" }
Usage in Dart:
DARTText(l10n.itemCount(0)); // "No items" Text(l10n.itemCount(1)); // "One item" Text(l10n.itemCount(5)); // "5 items"
Gender Forms
Some languages require different text based on gender:
JSON1{ 2 "replyMessage": "{gender, select, male{He replied to your message} female{She replied to your message} other{They replied to your message}}", 3 "@replyMessage": { 4 "description": "Notification message for replies", 5 "placeholders": { 6 "gender": { 7 "type": "String" 8 } 9 } 10 } 11}
Usage:
DARTText(l10n.replyMessage('female')); // "She replied to your message"
Combining Plurals and Gender
You can nest these patterns for complex scenarios:
JSON1{ 2 "friendRequest": "{count, plural, =1{{gender, select, male{He} female{She} other{They}} sent you a friend request} other{{count} people sent you friend requests}}", 3 "@friendRequest": { 4 "placeholders": { 5 "count": {"type": "int"}, 6 "gender": {"type": "String"} 7 } 8 } 9}
Date, Time, and Number Formatting
ARB files support locale-aware formatting for dates, times, and numbers.
Date Formatting
JSON1{ 2 "lastSeen": "Last seen: {date}", 3 "@lastSeen": { 4 "placeholders": { 5 "date": { 6 "type": "DateTime", 7 "format": "yMMMd" 8 } 9 } 10 } 11}
Common date formats:
yMMMd: Feb 12, 2026yMMMMd: February 12, 2026jm: 5:30 PMyMd: 2/12/2026
Usage:
DARTText(l10n.lastSeen(DateTime.now())); // English: "Last seen: Feb 12, 2026" // Spanish: "Last seen: 12 feb 2026"
Number and Currency Formatting
JSON1{ 2 "price": "Price: {amount}", 3 "@price": { 4 "placeholders": { 5 "amount": { 6 "type": "double", 7 "format": "currency", 8 "optionalParameters": { 9 "symbol": "$", 10 "decimalDigits": 2 11 } 12 } 13 } 14 }, 15 "discount": "Save {percent}%", 16 "@discount": { 17 "placeholders": { 18 "percent": { 19 "type": "int", 20 "format": "compact" 21 } 22 } 23 } 24}
Usage:
DARTText(l10n.price(29.99)); // "Price: $29.99" Text(l10n.discount(15)); // "Save 15%"
Organizing Large Translation Files
As your app grows, managing translations becomes more complex. Here are strategies for keeping things organized.
Namespace by Feature
Instead of one massive ARB file, split translations by feature:
lib/l10n/
├── app_en.arb # Global strings
├── auth_en.arb # Authentication
├── profile_en.arb # User profile
├── settings_en.arb # Settings
└── ...
Update l10n.yaml:
YAML1arb-dir: lib/l10n 2template-arb-file: app_en.arb 3output-localization-file: app_localizations.dart 4synthetic-package: false
Use Descriptive Key Names
Bad:
JSON1{ 2 "btn1": "Submit", 3 "msg2": "Error occurred" 4}
Good:
JSON1{ 2 "submitButton": "Submit", 3 "networkErrorMessage": "Error occurred" 4}
Add Context with Metadata
Always include descriptions and examples:
JSON1{ 2 "tapToEdit": "Tap to edit", 3 "@tapToEdit": { 4 "description": "Hint text shown below editable fields", 5 "context": "Used in profile screen for bio field" 6 } 7}
This helps translators understand where and how the text is used, leading to better translations.
Streamlining Workflows with IntlPull
Managing ARB files manually works for small projects, but as your team and translation count grow, you need better tooling. IntlPull provides a complete translation management platform specifically designed for developers.
Why IntlPull for Flutter?
Native ARB Support: IntlPull understands ARB file structure, including ICU MessageFormat, placeholders, and metadata.
Visual ICU Editor: Create complex plural and gender forms without memorizing ICU syntax.
CLI Integration: Automate translation sync in your CI/CD pipeline.
OTA Updates: Deploy translation fixes instantly without app store submissions.
Chrome Extension: Edit translations directly in your running app.
Setting Up IntlPull CLI
Install the CLI globally:
Terminalnpm install -g @intlpullhq/cli
Initialize in your Flutter project:
Terminalcd my_flutter_app intlpull init --framework flutter
The CLI auto-detects your ARB files and creates a configuration file:
JSON1{ 2 "projectId": "your-project-id", 3 "apiKey": "ip_live_...", 4 "framework": "flutter", 5 "sourcePath": "lib/l10n", 6 "format": "arb", 7 "languages": ["en", "es", "fr", "de"], 8 "defaultLanguage": "en" 9}
Push and Pull Translations
Upload your existing ARB files:
Terminalintlpull push
Download latest translations:
Terminalintlpull pull
Watch for changes during development:
Terminalintlpull watch
This command syncs translations in real-time as your team makes updates in the IntlPull dashboard.
Visual Translation Editing
IntlPull's dashboard provides a rich editing experience:
- See all languages side-by-side
- Visual ICU editor for plurals and gender forms
- Screenshot context for each translation
- Comment threads for translator questions
- Translation memory suggestions
- AI-powered translation with context awareness
Automated String Extraction
IntlPull can scan your Dart code and extract hardcoded strings:
Terminalintlpull scan --auto-wrap
Before:
DARTText('Welcome to our app')
After:
DARTText(AppLocalizations.of(context)!.welcomeToOurApp)
The CLI automatically adds the new key to your ARB files.
OTA Translation Updates
One of IntlPull's most powerful features is Over-The-Air (OTA) updates. Fix typos and update translations without going through the app store review process.
Add the OTA package:
Terminalflutter pub add intlpull_ota
Initialize in your app:
DART1import 'package:intlpull_ota/intlpull_ota.dart'; 2 3void main() async { 4 WidgetsFlutterBinding.ensureInitialized(); 5 6 final ota = IntlPullOTA( 7 projectId: 'your-project-id', 8 apiKey: 'ip_live_...', 9 environment: 'production', 10 ); 11 12 await ota.initialize(); 13 14 runApp(MyApp(ota: ota)); 15}
Integrate with your localization:
DART1class MyApp extends StatelessWidget { 2 final IntlPullOTA ota; 3 const MyApp({required this.ota, super.key}); 4 5 6 Widget build(BuildContext context) { 7 return OTAProvider( 8 ota: ota, 9 child: MaterialApp( 10 localizationsDelegates: [ 11 OTALocalizationsDelegate(ota), 12 AppLocalizations.delegate, 13 GlobalMaterialLocalizations.delegate, 14 GlobalWidgetsLocalizations.delegate, 15 ], 16 supportedLocales: const [ 17 Locale('en'), 18 Locale('es'), 19 Locale('fr'), 20 ], 21 home: const HomeScreen(), 22 ), 23 ); 24 } 25}
Now when you publish updates from the CLI:
Terminalintlpull publish --version 1.2.0 --message "Fixed checkout button typo"
All users receive the update on their next app launch. No app store submission, no waiting for review, no forcing users to update.
CI/CD Integration
Add translation validation to your CI pipeline:
YAML1# .github/workflows/ci.yml 2name: CI 3 4on: [push, pull_request] 5 6jobs: 7 validate-translations: 8 runs-on: ubuntu-latest 9 steps: 10 - uses: actions/checkout@v3 11 12 - name: Setup Node 13 uses: actions/setup-node@v3 14 with: 15 node-version: '18' 16 17 - name: Install IntlPull CLI 18 run: npm install -g @intlpullhq/cli 19 20 - name: Validate Translations 21 env: 22 INTLPULL_API_KEY: ${{ secrets.INTLPULL_API_KEY }} 23 run: | 24 intlpull validate --missing --unused --fail-on-error 25 26 - name: Pull Latest Translations 27 run: intlpull pull 28 29 - name: Generate Localizations 30 run: flutter gen-l10n
This ensures:
- No missing translations in any language
- No unused keys cluttering your ARB files
- Latest translations are always included in builds
Testing Your Internationalization
Proper testing ensures your app works correctly across all supported languages.
Unit Tests for Translations
Test that translations load correctly:
DART1import 'package:flutter_test/flutter_test.dart'; 2import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 4void main() { 5 test('English translations load', () async { 6 final localizations = await AppLocalizations.delegate.load( 7 const Locale('en'), 8 ); 9 10 expect(localizations.appTitle, 'My Awesome App'); 11 expect(localizations.greeting('Alice'), 'Hello, Alice!'); 12 }); 13 14 test('Spanish translations load', () async { 15 final localizations = await AppLocalizations.delegate.load( 16 const Locale('es'), 17 ); 18 19 expect(localizations.appTitle, 'Mi Aplicación Increíble'); 20 }); 21 22 test('Plural forms work correctly', () async { 23 final localizations = await AppLocalizations.delegate.load( 24 const Locale('en'), 25 ); 26 27 expect(localizations.itemCount(0), 'No items'); 28 expect(localizations.itemCount(1), 'One item'); 29 expect(localizations.itemCount(5), '5 items'); 30 }); 31}
Widget Tests
Test that your UI displays translations correctly:
DART1testWidgets('Home screen shows translated title', (tester) async { 2 await tester.pumpWidget( 3 MaterialApp( 4 localizationsDelegates: const [ 5 AppLocalizations.delegate, 6 GlobalMaterialLocalizations.delegate, 7 GlobalWidgetsLocalizations.delegate, 8 ], 9 supportedLocales: const [Locale('en')], 10 home: const HomeScreen(), 11 ), 12 ); 13 14 expect(find.text('My Awesome App'), findsOneWidget); 15 expect(find.text('Welcome to our app!'), findsOneWidget); 16});
Testing Different Locales
Force a specific locale for testing:
DART1testWidgets('Spanish translations display correctly', (tester) async { 2 await tester.pumpWidget( 3 MaterialApp( 4 locale: const Locale('es'), // Force Spanish 5 localizationsDelegates: const [ 6 AppLocalizations.delegate, 7 GlobalMaterialLocalizations.delegate, 8 GlobalWidgetsLocalizations.delegate, 9 ], 10 supportedLocales: const [Locale('en'), Locale('es')], 11 home: const HomeScreen(), 12 ), 13 ); 14 15 expect(find.text('Mi Aplicación Increíble'), findsOneWidget); 16});
Golden Tests for Layout
Test that UI doesn't break with longer translations:
DART1testWidgets('Button fits German translation', (tester) async { 2 await tester.pumpWidget( 3 MaterialApp( 4 locale: const Locale('de'), 5 localizationsDelegates: const [ 6 AppLocalizations.delegate, 7 GlobalMaterialLocalizations.delegate, 8 ], 9 supportedLocales: const [Locale('de')], 10 home: Scaffold( 11 body: ElevatedButton( 12 onPressed: () {}, 13 child: Text(AppLocalizations.of(context)!.submitButton), 14 ), 15 ), 16 ), 17 ); 18 19 await expectLater( 20 find.byType(ElevatedButton), 21 matchesGoldenFile('golden/button_de.png'), 22 ); 23});
German words tend to be longer, so this catches layout overflow issues.
Best Practices and Common Pitfalls
Do: Use Descriptive Keys
Keys should describe the content, not the location:
Bad:
JSON1{ 2 "homeScreen_title": "Welcome", 3 "screen2_button1": "Submit" 4}
Good:
JSON1{ 2 "welcomeTitle": "Welcome", 3 "submitButton": "Submit" 4}
Do: Provide Context
Always add descriptions and examples:
JSON1{ 2 "save": "Save", 3 "@save": { 4 "description": "Button text for saving user profile changes", 5 "context": "Profile edit screen" 6 } 7}
Don't: Concatenate Translations
Bad:
DARTText(l10n.hello + ' ' + userName + '!');
Good:
JSON{ "greeting": "Hello, {name}!" }
DARTText(l10n.greeting(userName));
Different languages have different word orders, so concatenation breaks translations.
Don't: Reuse Keys for Different Contexts
Bad:
JSON{ "delete": "Delete" }
Used for both "Delete Account" and "Delete Message" buttons.
Good:
JSON1{ 2 "deleteAccount": "Delete Account", 3 "deleteMessage": "Delete Message" 4}
Context matters for accurate translation.
Do: Handle RTL Languages
If supporting Arabic or Hebrew, test RTL layout:
DART1MaterialApp( 2 localizationsDelegates: [...], 3 supportedLocales: const [ 4 Locale('en'), 5 Locale('ar'), // Right-to-left 6 ], 7 builder: (context, child) { 8 final locale = Localizations.localeOf(context); 9 final isRTL = locale.languageCode == 'ar' || locale.languageCode == 'he'; 10 11 return Directionality( 12 textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr, 13 child: child!, 14 ); 15 }, 16)
Do: Cache Localizations
For performance, cache the localizations object:
DART1class HomeScreen extends StatefulWidget { 2 const HomeScreen({super.key}); 3 4 5 State<HomeScreen> createState() => _HomeScreenState(); 6} 7 8class _HomeScreenState extends State<HomeScreen> { 9 late final AppLocalizations l10n; 10 11 12 void didChangeDependencies() { 13 super.didChangeDependencies(); 14 l10n = AppLocalizations.of(context)!; 15 } 16 17 18 Widget build(BuildContext context) { 19 return Scaffold( 20 appBar: AppBar( 21 title: Text(l10n.appTitle), // Cached lookup 22 ), 23 body: Text(l10n.welcomeMessage), 24 ); 25 } 26}
Advanced Topics
Region-Specific Locales
Support regional variations like US vs UK English:
lib/l10n/
├── app_en_US.arb
├── app_en_GB.arb
├── app_es_ES.arb
├── app_es_MX.arb
DART1supportedLocales: const [ 2 Locale('en', 'US'), 3 Locale('en', 'GB'), 4 Locale('es', 'ES'), 5 Locale('es', 'MX'), 6],
Fallback Locales
Flutter automatically falls back to the base language if a regional variant isn't found:
User locale: es_AR (Spanish Argentina)
Available: en, es_ES, fr
Result: Uses es_ES (falls back to Spanish Spain)
Dynamic Locale Switching
Allow users to change language without restarting:
DART1class MyApp extends StatefulWidget { 2 const MyApp({super.key}); 3 4 5 State<MyApp> createState() => _MyAppState(); 6} 7 8class _MyAppState extends State<MyApp> { 9 Locale _locale = const Locale('en'); 10 11 void _changeLocale(Locale locale) { 12 setState(() { 13 _locale = locale; 14 }); 15 } 16 17 18 Widget build(BuildContext context) { 19 return MaterialApp( 20 locale: _locale, 21 localizationsDelegates: const [ 22 AppLocalizations.delegate, 23 GlobalMaterialLocalizations.delegate, 24 GlobalWidgetsLocalizations.delegate, 25 ], 26 supportedLocales: const [ 27 Locale('en'), 28 Locale('es'), 29 Locale('fr'), 30 ], 31 home: HomeScreen(onLocaleChange: _changeLocale), 32 ); 33 } 34}
Platform-Specific Strings
Handle iOS vs Android terminology differences:
JSON1{ 2 "tapAction": "{platform, select, ios{Tap} android{Tap} other{Click}}", 3 "@tapAction": { 4 "placeholders": { 5 "platform": {"type": "String"} 6 } 7 } 8}
DART1import 'dart:io'; 2 3String getPlatform() { 4 if (Platform.isIOS) return 'ios'; 5 if (Platform.isAndroid) return 'android'; 6 return 'other'; 7} 8 9Text(l10n.tapAction(getPlatform()));
Production Checklist
Before launching your internationalized app:
- All ARB files contain the same keys
- Metadata includes descriptions for all strings
- Plural forms tested for all languages
- Date/time formats verified per locale
- Number/currency formats tested
- RTL layouts tested (if supporting Arabic/Hebrew)
- Translation memory populated in IntlPull
- CI/CD validates translations before merge
- OTA updates configured for production
- Team trained on ARB file editing workflow
- Fallback locale configured (usually English)
- Golden tests created for critical screens
Conclusion
Internationalizing your Flutter app with ARB files provides a robust, type-safe foundation for reaching global audiences. By following the patterns in this tutorial, you'll avoid common pitfalls and build a maintainable localization system that scales with your app.
Key takeaways:
- ARB files provide rich metadata and ICU MessageFormat support
- Flutter's code generation ensures type safety and catches errors early
- Proper organization and naming conventions keep translations manageable
- IntlPull streamlines team collaboration and deployment workflows
- OTA updates eliminate the app store bottleneck for translation fixes
- Comprehensive testing ensures quality across all supported languages
Ready to streamline your Flutter localization workflow? Try IntlPull free with 100 keys and 3 languages, or explore our Flutter documentation for advanced integration patterns. Your global users will thank you for the attention to linguistic detail, and your development team will appreciate the time saved.
