← Back to Blog

Flutter Localization with Modular Architecture: Feature-First i18n Patterns

flutterlocalizationarchitecturemodularfeature-firstscalability

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.