Flutter Localization with Modular Architecture: Feature-First i18n Patterns
Building large Flutter apps requires thoughtful architecture. When your app grows to dozens of features, organizing localization becomes challenging. This guide shows you how to implement localization in modular, feature-first Flutter architectures.
Why Modular Localization Matters
In a monolithic app, all translations live in one place:
lib/
├── l10n/
│ ├── app_en.arb
│ ├── app_es.arb
│ └── app_fr.arb
└── features/
├── auth/
├── home/
└── settings/
This works for small apps, but causes problems at scale:
- Merge conflicts: Multiple developers editing the same ARB files
- Bundle size: Loading all translations upfront
- Coupling: Features depend on a central localization module
- Testing: Hard to test features in isolation
Modular Architecture Overview
With modular localization, each feature owns its translations:
lib/
├── core/
│ └── l10n/
│ ├── core_en.arb
│ └── core_es.arb
├── features/
│ ├── auth/
│ │ ├── l10n/
│ │ │ ├── auth_en.arb
│ │ │ └── auth_es.arb
│ │ ├── presentation/
│ │ └── domain/
│ ├── home/
│ │ ├── l10n/
│ │ │ ├── home_en.arb
│ │ │ └── home_es.arb
│ │ └── ...
│ └── settings/
│ ├── l10n/
│ │ ├── settings_en.arb
│ │ └── settings_es.arb
│ └── ...
Setting Up Modular Localization
Step 1: Configure Multiple ARB Directories
Create a separate l10n.yaml for each feature:
# features/auth/l10n.yaml
arb-dir: lib/features/auth/l10n
template-arb-file: auth_en.arb
output-localization-file: auth_localizations.dart
output-class: AuthLocalizations
output-dir: lib/features/auth/l10n/generated
synthetic-package: false
Step 2: Create Feature-Specific ARB Files
features/auth/l10n/auth_en.arb:
{
"@@locale": "en",
"loginTitle": "Welcome Back",
"@loginTitle": {
"description": "Title shown on login screen"
},
"loginButton": "Sign In",
"forgotPassword": "Forgot Password?",
"createAccount": "Create Account",
"emailLabel": "Email",
"passwordLabel": "Password",
"loginError": "Invalid email or password"
}
features/auth/l10n/auth_es.arb:
{
"@@locale": "es",
"loginTitle": "Bienvenido de Nuevo",
"loginButton": "Iniciar Sesión",
"forgotPassword": "¿Olvidaste tu Contraseña?",
"createAccount": "Crear Cuenta",
"emailLabel": "Correo Electrónico",
"passwordLabel": "Contraseña",
"loginError": "Correo o contraseña inválidos"
}
Step 3: Generate Localizations per Feature
Run the generator for each feature:
flutter gen-l10n --arb-dir=lib/features/auth/l10n \
--template-arb-file=auth_en.arb \
--output-localization-file=auth_localizations.dart \
--output-class=AuthLocalizations \
--output-dir=lib/features/auth/l10n/generated \
--no-synthetic-package
Create a script to generate all features:
#!/bin/bash
# scripts/generate_l10n.sh
features=("auth" "home" "settings" "profile" "cart")
for feature in "${features[@]}"; do
echo "Generating $feature localizations..."
flutter gen-l10n \
--arb-dir=lib/features/$feature/l10n \
--template-arb-file=${feature}_en.arb \
--output-localization-file=${feature}_localizations.dart \
--output-class=$(echo "${feature^}")Localizations \
--output-dir=lib/features/$feature/l10n/generated \
--no-synthetic-package
done
echo "All localizations generated!"
Using Feature Localizations
Option 1: Direct Access
Access translations directly within the feature:
// features/auth/presentation/login_page.dart
import '../l10n/generated/auth_localizations.dart';
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AuthLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.loginTitle)),
body: Column(
children: [
TextField(
decoration: InputDecoration(labelText: l10n.emailLabel),
),
TextField(
decoration: InputDecoration(labelText: l10n.passwordLabel),
obscureText: true,
),
ElevatedButton(
onPressed: () {},
child: Text(l10n.loginButton),
),
],
),
);
}
}
Option 2: Extension Methods
Create extensions for cleaner access:
// features/auth/l10n/auth_l10n_extension.dart
import 'package:flutter/widgets.dart';
import 'generated/auth_localizations.dart';
extension AuthL10nExtension on BuildContext {
AuthLocalizations get authL10n => AuthLocalizations.of(this)!;
}
// Usage in widgets:
Text(context.authL10n.loginTitle)
Option 3: Unified Localization Facade
Create a facade that combines all feature localizations:
// core/l10n/app_localizations_facade.dart
import 'package:flutter/widgets.dart';
import '../../features/auth/l10n/generated/auth_localizations.dart';
import '../../features/home/l10n/generated/home_localizations.dart';
import '../../features/settings/l10n/generated/settings_localizations.dart';
class AppL10n {
final AuthLocalizations auth;
final HomeLocalizations home;
final SettingsLocalizations settings;
AppL10n._({
required this.auth,
required this.home,
required this.settings,
});
static AppL10n of(BuildContext context) {
return AppL10n._(
auth: AuthLocalizations.of(context)!,
home: HomeLocalizations.of(context)!,
settings: SettingsLocalizations.of(context)!,
);
}
}
// Extension for easy access
extension AppL10nExtension on BuildContext {
AppL10n get l10n => AppL10n.of(this);
}
// Usage:
Text(context.l10n.auth.loginTitle)
Text(context.l10n.home.welcomeMessage)
Registering Multiple Localization Delegates
Register all feature delegates in your app:
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'features/auth/l10n/generated/auth_localizations.dart';
import 'features/home/l10n/generated/home_localizations.dart';
import 'features/settings/l10n/generated/settings_localizations.dart';
import 'core/l10n/generated/core_localizations.dart';
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: [
// Core localizations (shared strings)
CoreLocalizations.delegate,
// Feature localizations
AuthLocalizations.delegate,
HomeLocalizations.delegate,
SettingsLocalizations.delegate,
// Flutter built-in localizations
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: [
Locale('en'),
Locale('es'),
Locale('fr'),
Locale('de'),
],
home: HomePage(),
);
}
}
Lazy Loading Feature Translations
For very large apps, load translations only when needed:
// core/l10n/lazy_localization_loader.dart
import 'dart:convert';
import 'package:flutter/services.dart';
class LazyLocalizationLoader {
static final Map<String, Map<String, String>> _cache = {};
static Future<Map<String, String>> loadFeature(
String feature,
String locale,
) async {
final cacheKey = '${feature}_$locale';
if (_cache.containsKey(cacheKey)) {
return _cache[cacheKey]!;
}
try {
final jsonString = await rootBundle.loadString(
'assets/l10n/$feature/${feature}_$locale.json',
);
final Map<String, dynamic> jsonMap = json.decode(jsonString);
final translations = jsonMap.map(
(key, value) => MapEntry(key, value.toString()),
);
_cache[cacheKey] = translations;
return translations;
} catch (e) {
// Fallback to English
return loadFeature(feature, 'en');
}
}
static void clearCache() {
_cache.clear();
}
}
// Usage in a feature module:
class CartPage extends StatefulWidget {
@override
State<CartPage> createState() => _CartPageState();
}
class _CartPageState extends State<CartPage> {
Map<String, String>? _translations;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_loadTranslations();
}
Future<void> _loadTranslations() async {
final locale = Localizations.localeOf(context).languageCode;
final translations = await LazyLocalizationLoader.loadFeature(
'cart',
locale,
);
setState(() => _translations = translations);
}
String tr(String key) => _translations?[key] ?? key;
@override
Widget build(BuildContext context) {
if (_translations == null) {
return Center(child: CircularProgressIndicator());
}
return Scaffold(
appBar: AppBar(title: Text(tr('cartTitle'))),
body: ListView(
children: [
Text(tr('emptyCartMessage')),
ElevatedButton(
onPressed: () {},
child: Text(tr('continueShopping')),
),
],
),
);
}
}
Shared Core Translations
Keep commonly used strings in a core module:
// core/l10n/core_en.arb
{
"@@locale": "en",
"ok": "OK",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"loading": "Loading...",
"error": "Something went wrong",
"retry": "Retry",
"yes": "Yes",
"no": "No",
"back": "Back",
"next": "Next",
"done": "Done",
"search": "Search",
"noResults": "No results found"
}
Features can use both core and feature-specific translations:
class ProductDetailPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final core = CoreLocalizations.of(context)!;
final product = ProductLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(product.productDetails)),
body: Column(
children: [
// Feature-specific
Text(product.addToCart),
Text(product.outOfStock),
// Core shared strings
ElevatedButton(
onPressed: () {},
child: Text(core.save),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(core.cancel),
),
],
),
);
}
}
Testing Modular Localizations
Test features in isolation with their own localizations:
// features/auth/test/login_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/features/auth/l10n/generated/auth_localizations.dart';
import 'package:myapp/features/auth/presentation/login_page.dart';
void main() {
Widget createTestWidget() {
return MaterialApp(
localizationsDelegates: [
AuthLocalizations.delegate,
],
supportedLocales: [Locale('en')],
home: LoginPage(),
);
}
testWidgets('displays login title', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
expect(find.text('Welcome Back'), findsOneWidget);
});
testWidgets('displays form labels', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
expect(find.text('Email'), findsOneWidget);
expect(find.text('Password'), findsOneWidget);
});
}
Build Script for CI/CD
Automate localization generation in your CI pipeline:
# .github/workflows/build.yml
name: Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.16.0'
- name: Generate all localizations
run: |
chmod +x scripts/generate_l10n.sh
./scripts/generate_l10n.sh
- name: Verify no uncommitted changes
run: |
git diff --exit-code lib/features/*/l10n/generated/
- name: Run tests
run: flutter test
- name: Build
run: flutter build apk --release
Best Practices
1. Namespace Your Keys
Prefix keys to avoid conflicts:
// auth_en.arb - Good
{
"auth_loginTitle": "Login",
"auth_loginButton": "Sign In"
}
// auth_en.arb - Risky (could conflict with other features)
{
"title": "Login",
"button": "Sign In"
}
2. Keep Core Minimal
Only put truly shared strings in core:
Core: OK, Cancel, Save, Delete, Error messages
Feature: Everything specific to that feature
3. Document Feature Dependencies
If a feature uses another feature's translations, document it:
/// This feature depends on:
/// - CoreLocalizations (shared UI strings)
/// - ProductLocalizations (product display strings)
library cart_feature;
4. Version Your Translations
Track translation versions for debugging:
{
"@@locale": "en",
"@@version": "1.2.0",
"@@last_modified": "2025-12-16"
}
Conclusion
Modular localization scales with your Flutter app. By organizing translations per feature:
- Teams work independently without merge conflicts
- Features can be tested in isolation
- Bundle size stays manageable with lazy loading
- Code stays maintainable as the app grows
Start with a monolithic approach for small apps, then migrate to modular localization as your team and codebase grow.
Ready to manage translations across multiple features? FlutterLocalisation supports modular ARB file workflows with team collaboration, making it easy to organize translations by feature while keeping everything in sync.