← Back to Blog

Flutter Firebase Remote Translations: Load Translations from the Cloud

flutterfirebaseremote-configfirestorelocalizationcloud

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

  1. Always have local fallbacks - Never rely solely on remote translations
  2. Cache aggressively - Store fetched translations locally
  3. Fetch in background - Don't block app startup
  4. Version your translations - Track which version users have
  5. Monitor fetch failures - Log errors to analytics
  6. 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.