Flutter Firebase Remote Translations: Load Translations from the Cloud
Want to update your app's translations without releasing a new version? Firebase Remote Config and Cloud Firestore let you manage translations remotely, enabling instant updates across all users.
This guide shows you how to implement cloud-based translations in Flutter while maintaining offline support and performance.
Why Remote Translations?
Traditional ARB-based localization requires app updates for translation changes. Remote translations offer:
- Instant updates: Fix typos or add new languages without app store review
- A/B testing: Test different copy to improve conversion
- Dynamic content: Update seasonal messages or promotions
- Faster iteration: Translators work independently of release cycles
- Gradual rollout: Test translations with a percentage of users first
Architecture Overview
┌─────────────────┐ ┌──────────────────┐
│ Flutter App │────▶│ Firebase/Cloud │
└─────────────────┘ └──────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ Local Cache │ │ Remote Config │
│ (Fallback) │ │ or Firestore │
└─────────────────┘ └──────────────────┘
Method 1: Firebase Remote Config
Best for: Simple key-value translations, A/B testing copy.
Setup Firebase Remote Config
# pubspec.yaml
dependencies:
firebase_core: ^2.24.0
firebase_remote_config: ^4.3.0
Create Remote Translation Service
import 'package:firebase_remote_config/firebase_remote_config.dart';
class RemoteTranslationService {
static final RemoteTranslationService _instance =
RemoteTranslationService._internal();
factory RemoteTranslationService() => _instance;
RemoteTranslationService._internal();
final FirebaseRemoteConfig _remoteConfig = FirebaseRemoteConfig.instance;
Map<String, String> _translations = {};
String _currentLocale = 'en';
Future<void> initialize() async {
await _remoteConfig.setConfigSettings(RemoteConfigSettings(
fetchTimeout: const Duration(minutes: 1),
minimumFetchInterval: const Duration(hours: 1),
));
// Set defaults from local ARB files
await _remoteConfig.setDefaults(_getLocalDefaults());
// Fetch remote translations
await fetchTranslations();
}
Future<void> fetchTranslations() async {
try {
await _remoteConfig.fetchAndActivate();
_loadTranslationsForLocale(_currentLocale);
} catch (e) {
print('Failed to fetch remote translations: $e');
// Falls back to defaults automatically
}
}
void _loadTranslationsForLocale(String locale) {
_translations = {};
// Remote Config stores translations as JSON strings per locale
final jsonString = _remoteConfig.getString('translations_$locale');
if (jsonString.isNotEmpty) {
final Map<String, dynamic> data = json.decode(jsonString);
_translations = data.map((key, value) =>
MapEntry(key, value.toString()));
}
}
String translate(String key, {Map<String, String>? params}) {
String translation = _translations[key] ?? key;
// Replace parameters
if (params != null) {
params.forEach((paramKey, value) {
translation = translation.replaceAll('{$paramKey}', value);
});
}
return translation;
}
Future<void> setLocale(String locale) async {
_currentLocale = locale;
_loadTranslationsForLocale(locale);
}
Map<String, dynamic> _getLocalDefaults() {
// Load from bundled ARB files as fallback
return {
'translations_en': json.encode({
'welcome': 'Welcome',
'greeting': 'Hello, {name}!',
'items_count': '{count} items',
}),
'translations_es': json.encode({
'welcome': 'Bienvenido',
'greeting': '¡Hola, {name}!',
'items_count': '{count} artículos',
}),
};
}
}
Configure Firebase Console
In Firebase Console > Remote Config, add parameters:
// Parameter: translations_en
{
"welcome": "Welcome to Our App",
"greeting": "Hello, {name}!",
"cta_button": "Get Started Free",
"items_count": "{count} items"
}
// Parameter: translations_es
{
"welcome": "Bienvenido a Nuestra App",
"greeting": "¡Hola, {name}!",
"cta_button": "Comenzar Gratis",
"items_count": "{count} artículos"
}
Method 2: Cloud Firestore
Best for: Large translation sets, real-time updates, complex structures.
Firestore Structure
translations/
├── en/
│ ├── common: { welcome: "Welcome", ... }
│ ├── auth: { login: "Log In", ... }
│ └── errors: { network: "No connection", ... }
├── es/
│ ├── common: { welcome: "Bienvenido", ... }
│ ├── auth: { login: "Iniciar Sesión", ... }
│ └── errors: { network: "Sin conexión", ... }
└── fr/
└── ...
Firestore Translation Service
import 'package:cloud_firestore/cloud_firestore.dart';
class FirestoreTranslationService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final Map<String, Map<String, String>> _cache = {};
String _currentLocale = 'en';
StreamSubscription? _subscription;
Future<void> initialize(String locale) async {
_currentLocale = locale;
await _loadTranslations(locale);
_listenForUpdates(locale);
}
Future<void> _loadTranslations(String locale) async {
if (_cache.containsKey(locale)) return;
try {
final snapshot = await _firestore
.collection('translations')
.doc(locale)
.collection('strings')
.get();
final translations = <String, String>{};
for (final doc in snapshot.docs) {
final data = doc.data();
data.forEach((key, value) {
translations['${doc.id}.$key'] = value.toString();
});
}
_cache[locale] = translations;
} catch (e) {
print('Failed to load translations: $e');
// Use local fallback
_cache[locale] = _getLocalFallback(locale);
}
}
void _listenForUpdates(String locale) {
_subscription?.cancel();
_subscription = _firestore
.collection('translations')
.doc(locale)
.collection('strings')
.snapshots()
.listen((snapshot) {
for (final change in snapshot.docChanges) {
if (change.type == DocumentChangeType.modified ||
change.type == DocumentChangeType.added) {
final data = change.doc.data()!;
data.forEach((key, value) {
_cache[locale]?['${change.doc.id}.$key'] = value.toString();
});
}
}
});
}
String translate(String key, {Map<String, String>? params}) {
final translations = _cache[_currentLocale] ?? {};
String translation = translations[key] ?? key;
if (params != null) {
params.forEach((paramKey, value) {
translation = translation.replaceAll('{$paramKey}', value);
});
}
return translation;
}
Future<void> setLocale(String locale) async {
_currentLocale = locale;
await _loadTranslations(locale);
_listenForUpdates(locale);
}
Map<String, String> _getLocalFallback(String locale) {
// Return bundled translations
return {};
}
void dispose() {
_subscription?.cancel();
}
}
Hybrid Approach: Local + Remote
The best strategy combines bundled ARB files with remote updates:
class HybridTranslationService {
final Map<String, Map<String, String>> _localTranslations = {};
final Map<String, Map<String, String>> _remoteTranslations = {};
String _currentLocale = 'en';
Future<void> initialize() async {
// 1. Load bundled translations immediately
await _loadLocalTranslations();
// 2. Fetch remote updates in background
_fetchRemoteTranslations();
}
Future<void> _loadLocalTranslations() async {
// Load from ARB files bundled with app
final String jsonString = await rootBundle.loadString(
'lib/l10n/app_$_currentLocale.arb',
);
final data = json.decode(jsonString) as Map<String, dynamic>;
_localTranslations[_currentLocale] = data.map(
(key, value) => MapEntry(key, value.toString()),
);
}
Future<void> _fetchRemoteTranslations() async {
try {
final remoteConfig = FirebaseRemoteConfig.instance;
await remoteConfig.fetchAndActivate();
final jsonString = remoteConfig.getString(
'translations_$_currentLocale',
);
if (jsonString.isNotEmpty) {
final data = json.decode(jsonString) as Map<String, dynamic>;
_remoteTranslations[_currentLocale] = data.map(
(key, value) => MapEntry(key, value.toString()),
);
}
} catch (e) {
// Silent fail - local translations still work
print('Remote fetch failed: $e');
}
}
String translate(String key, {Map<String, String>? params}) {
// Remote translations override local
final remote = _remoteTranslations[_currentLocale];
final local = _localTranslations[_currentLocale];
String translation = remote?[key] ?? local?[key] ?? key;
if (params != null) {
params.forEach((paramKey, value) {
translation = translation.replaceAll('{$paramKey}', value);
});
}
return translation;
}
}
Integrating with Flutter's Localization System
Create a custom LocalizationsDelegate:
class RemoteLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
final RemoteTranslationService _service;
RemoteLocalizationsDelegate(this._service);
@override
bool isSupported(Locale locale) {
return ['en', 'es', 'fr', 'de', 'ar'].contains(locale.languageCode);
}
@override
Future<AppLocalizations> load(Locale locale) async {
await _service.setLocale(locale.languageCode);
return RemoteAppLocalizations(_service);
}
@override
bool shouldReload(RemoteLocalizationsDelegate old) => false;
}
class RemoteAppLocalizations implements AppLocalizations {
final RemoteTranslationService _service;
RemoteAppLocalizations(this._service);
@override
String get welcome => _service.translate('welcome');
@override
String greeting(String name) => _service.translate(
'greeting',
params: {'name': name},
);
@override
String itemsCount(int count) => _service.translate(
'items_count',
params: {'count': count.toString()},
);
}
Offline Support with Caching
Ensure translations work offline:
import 'package:shared_preferences/shared_preferences.dart';
class CachedTranslationService {
static const _cachePrefix = 'cached_translations_';
final SharedPreferences _prefs;
CachedTranslationService(this._prefs);
Future<void> cacheTranslations(
String locale,
Map<String, String> translations,
) async {
await _prefs.setString(
'$_cachePrefix$locale',
json.encode(translations),
);
await _prefs.setInt(
'${_cachePrefix}${locale}_timestamp',
DateTime.now().millisecondsSinceEpoch,
);
}
Map<String, String>? getCachedTranslations(String locale) {
final cached = _prefs.getString('$_cachePrefix$locale');
if (cached != null) {
return Map<String, String>.from(json.decode(cached));
}
return null;
}
bool isCacheValid(String locale, Duration maxAge) {
final timestamp = _prefs.getInt('${_cachePrefix}${locale}_timestamp');
if (timestamp == null) return false;
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
return DateTime.now().difference(cacheTime) < maxAge;
}
}
Performance Optimization
Lazy Loading by Module
class ModularTranslationService {
final Map<String, Map<String, String>> _loadedModules = {};
Future<void> loadModule(String module, String locale) async {
final key = '${locale}_$module';
if (_loadedModules.containsKey(key)) return;
final snapshot = await FirebaseFirestore.instance
.collection('translations')
.doc(locale)
.collection('modules')
.doc(module)
.get();
if (snapshot.exists) {
_loadedModules[key] = Map<String, String>.from(snapshot.data()!);
}
}
String translate(String module, String key, String locale) {
final moduleKey = '${locale}_$module';
return _loadedModules[moduleKey]?[key] ?? key;
}
}
Best Practices
- Always have local fallbacks - Never rely solely on remote translations
- Cache aggressively - Store fetched translations locally
- Fetch in background - Don't block app startup
- Version your translations - Track which version users have
- Monitor fetch failures - Log errors to analytics
- Test offline mode - Ensure app works without network
Conclusion
Remote translations give you flexibility to update content instantly. Use Firebase Remote Config for simple needs or Firestore for complex, real-time scenarios. Always maintain local fallbacks for reliability.
Start with a hybrid approach: bundle essential translations in the app, then enhance with remote updates for dynamic content and quick fixes.