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.