← Back to Blog

Flutter White-Label App Localization: Multi-Tenant Translation Strategies

flutterwhite-labelmulti-tenantlocalizationflavorsenterprise

Flutter White-Label App Localization: Multi-Tenant Translation Strategies

Building white-label Flutter apps that serve multiple clients with different languages and branding? Managing translations across tenants can quickly become a nightmare without the right architecture. This guide shows you proven patterns for multi-tenant localization.

Understanding White-Label Localization Challenges

White-label apps face unique localization challenges:

  • Multiple Clients: Each client may need different languages
  • Brand-Specific Terminology: "Cart" vs "Basket" vs "Bag"
  • Regional Variations: UK English vs US English per client
  • Dynamic Content: Client-specific labels and messages
  • Scalability: Adding new clients without code changes

Architecture Options for Multi-Tenant Localization

Option 1: Flavor-Based Localization

Use Flutter flavors to separate client translations:

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
lib/
├── l10n/
   ├── client_a/
      ├── app_en.arb
      ├── app_es.arb
      └── app_fr.arb
   ├── client_b/
      ├── app_en.arb
      ├── app_de.arb
      └── app_it.arb
   └── shared/
       ├── app_en.arb  # Common translations
       └── app_es.arb

Option 2: Remote Configuration

Load translations dynamically based on tenant:

class TenantLocalizationService {
  final String tenantId;
  final ApiClient _api;

  Map<String, Map<String, String>> _translations = {};

  TenantLocalizationService({
    required this.tenantId,
    required ApiClient api,
  }) : _api = api;

  Future<void> loadTranslations(Locale locale) async {
    final response = await _api.get(
      '/tenants/$tenantId/translations/${locale.languageCode}',
    );

    _translations[locale.languageCode] =
        Map<String, String>.from(response.data);
  }

  String translate(String key, Locale locale) {
    return _translations[locale.languageCode]?[key] ?? key;
  }
}

Option 3: Override Pattern

Create a base translation class that tenants can override:

abstract class BaseLocalizations {
  String get welcomeMessage;
  String get checkoutButton;
  String get cartTitle;
}

class DefaultLocalizations implements BaseLocalizations {
  @override
  String get welcomeMessage => 'Welcome!';

  @override
  String get checkoutButton => 'Checkout';

  @override
  String get cartTitle => 'Shopping Cart';
}

class ClientALocalizations implements BaseLocalizations {
  @override
  String get welcomeMessage => 'Welcome to Client A!';

  @override
  String get checkoutButton => 'Proceed to Payment';

  @override
  String get cartTitle => 'Your Basket';  // UK terminology
}

Implementing a Scalable Solution

Here's a production-ready implementation combining multiple approaches:

Step 1: Define Tenant Configuration

class TenantConfig {
  final String id;
  final String name;
  final List<Locale> supportedLocales;
  final Locale defaultLocale;
  final Map<String, String> brandTerms;

  const TenantConfig({
    required this.id,
    required this.name,
    required this.supportedLocales,
    required this.defaultLocale,
    this.brandTerms = const {},
  });
}

// Example configurations
const tenantConfigs = {
  'client_a': TenantConfig(
    id: 'client_a',
    name: 'Client A',
    supportedLocales: [Locale('en'), Locale('es'), Locale('fr')],
    defaultLocale: Locale('en'),
    brandTerms: {
      'cart': 'basket',
      'checkout': 'proceed to payment',
    },
  ),
  'client_b': TenantConfig(
    id: 'client_b',
    name: 'Client B',
    supportedLocales: [Locale('en'), Locale('de')],
    defaultLocale: Locale('de'),
    brandTerms: {
      'cart': 'warenkorb',
    },
  ),
};

Step 2: Create Tenant-Aware Localization Delegate

class TenantLocalizationsDelegate
    extends LocalizationsDelegate<AppLocalizations> {
  final TenantConfig tenant;
  final Map<String, Map<String, String>> _overrides;

  TenantLocalizationsDelegate({
    required this.tenant,
    Map<String, Map<String, String>>? overrides,
  }) : _overrides = overrides ?? {};

  @override
  bool isSupported(Locale locale) {
    return tenant.supportedLocales.contains(locale);
  }

  @override
  Future<AppLocalizations> load(Locale locale) async {
    // Load base translations
    final base = await AppLocalizations.delegate.load(locale);

    // Apply tenant overrides
    return TenantAppLocalizations(
      base: base,
      overrides: _overrides[locale.languageCode] ?? {},
      brandTerms: tenant.brandTerms,
    );
  }

  @override
  bool shouldReload(TenantLocalizationsDelegate old) {
    return tenant.id != old.tenant.id;
  }
}

Step 3: Wrap Translations with Brand Terms

class TenantAppLocalizations implements AppLocalizations {
  final AppLocalizations _base;
  final Map<String, String> _overrides;
  final Map<String, String> _brandTerms;

  TenantAppLocalizations({
    required AppLocalizations base,
    required Map<String, String> overrides,
    required Map<String, String> brandTerms,
  })  : _base = base,
        _overrides = overrides,
        _brandTerms = brandTerms;

  String _applyBrandTerms(String text) {
    var result = text;
    for (final entry in _brandTerms.entries) {
      result = result.replaceAll(
        RegExp(entry.key, caseSensitive: false),
        entry.value,
      );
    }
    return result;
  }

  @override
  String get cartTitle {
    final override = _overrides['cartTitle'];
    if (override != null) return override;
    return _applyBrandTerms(_base.cartTitle);
  }

  // Implement other getters...
}

Step 4: Initialize in Main App

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Get tenant from environment or config
  final tenantId = const String.fromEnvironment(
    'TENANT_ID',
    defaultValue: 'default',
  );

  final tenant = tenantConfigs[tenantId] ?? defaultTenant;

  // Load tenant-specific overrides
  final overrides = await loadTenantOverrides(tenant.id);

  runApp(MyApp(tenant: tenant, overrides: overrides));
}

class MyApp extends StatelessWidget {
  final TenantConfig tenant;
  final Map<String, Map<String, String>> overrides;

  const MyApp({
    required this.tenant,
    required this.overrides,
  });

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      localizationsDelegates: [
        TenantLocalizationsDelegate(
          tenant: tenant,
          overrides: overrides,
        ),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      supportedLocales: tenant.supportedLocales,
      locale: tenant.defaultLocale,
      // ...
    );
  }
}

Managing Translations with FlutterLocalisation

For white-label apps, FlutterLocalisation offers powerful features:

Flavors Support

Create separate flavor configurations for each client:

# flutterlocalisation.yaml
project: my-white-label-app
flavors:
  - name: client_a
    locales: [en, es, fr]
    output_dir: lib/l10n/client_a
  - name: client_b
    locales: [en, de]
    output_dir: lib/l10n/client_b
  - name: shared
    locales: [en, es, de, fr]
    output_dir: lib/l10n/shared

Team Collaboration

Assign translators to specific clients:

  • Client A translator: Only sees Client A keys
  • Client B translator: Only sees Client B keys
  • Shared translator: Manages common translations

Sync Per Flavor

# Sync specific client translations
flutter_localisation sync --flavor client_a

# Sync all flavors
flutter_localisation sync --all-flavors

Best Practices for White-Label Localization

1. Separate Shared vs Client-Specific Keys

// shared/app_en.arb - Common translations
{
  "@@locale": "en",
  "loading": "Loading...",
  "error_generic": "Something went wrong",
  "button_cancel": "Cancel",
  "button_confirm": "Confirm"
}

// client_a/app_en.arb - Client A specific
{
  "@@locale": "en",
  "app_name": "Client A App",
  "welcome_message": "Welcome to Client A!",
  "cart_title": "Your Basket"
}

2. Use Interpolation for Brand Names

{
  "welcome_branded": "Welcome to {brandName}!",
  "@welcome_branded": {
    "placeholders": {
      "brandName": {"type": "String"}
    }
  }
}
// Usage
Text(AppLocalizations.of(context)!.welcome_branded(tenant.name))

3. Implement Fallback Chain

String getTranslation(String key) {
  // 1. Try client-specific translation
  final clientTranslation = _clientTranslations[key];
  if (clientTranslation != null) return clientTranslation;

  // 2. Fall back to shared translation
  final sharedTranslation = _sharedTranslations[key];
  if (sharedTranslation != null) return sharedTranslation;

  // 3. Fall back to key itself (for debugging)
  return key;
}

4. Cache Aggressively

class TranslationCache {
  static final Map<String, Map<String, String>> _cache = {};

  static Future<Map<String, String>> getTranslations(
    String tenantId,
    String locale,
  ) async {
    final cacheKey = '$tenantId:$locale';

    if (_cache.containsKey(cacheKey)) {
      return _cache[cacheKey]!;
    }

    final translations = await _loadFromSource(tenantId, locale);
    _cache[cacheKey] = translations;
    return translations;
  }

  static void invalidate(String tenantId) {
    _cache.removeWhere((key, _) => key.startsWith('$tenantId:'));
  }
}

Testing White-Label Localization

void main() {
  group('White-label localization', () {
    test('Client A uses basket terminology', () async {
      final tenant = tenantConfigs['client_a']!;
      final delegate = TenantLocalizationsDelegate(tenant: tenant);

      final localizations = await delegate.load(const Locale('en'));

      expect(localizations.cartTitle, contains('Basket'));
    });

    test('Client B uses German default', () async {
      final tenant = tenantConfigs['client_b']!;

      expect(tenant.defaultLocale, equals(const Locale('de')));
    });

    test('Unsupported locale falls back correctly', () async {
      final tenant = tenantConfigs['client_a']!;
      final delegate = TenantLocalizationsDelegate(tenant: tenant);

      // Client A doesn't support German
      expect(delegate.isSupported(const Locale('de')), isFalse);
    });
  });
}

Conclusion

White-label localization requires careful planning but pays off with:

  • Faster Client Onboarding: Add new clients without code changes
  • Consistent Quality: Shared translations maintain consistency
  • Client Satisfaction: Each client gets their preferred terminology
  • Scalable Architecture: Grows with your business

Use FlutterLocalisation's flavor support to manage multi-tenant translations efficiently. The platform handles the complexity while you focus on building great apps for each client.


Need to manage white-label translations? Try FlutterLocalisation free with built-in flavor support.