← Back to Blog

Flutter Localization with Supabase: Remote Translations Management

fluttersupabaselocalizationremotereal-timepostgresql

Flutter Localization with Supabase: Remote Translations Management

Supabase has become a popular Firebase alternative for Flutter developers. Its PostgreSQL database, real-time subscriptions, and edge functions make it an excellent choice for managing remote translations. This guide shows you how to build a complete remote localization system with Supabase.

Why Use Supabase for Translations?

Traditional Flutter localization bundles translations with the app. Supabase enables:

  • Update translations without app releases - Fix typos, improve wording instantly
  • A/B test translations - Try different copy and measure conversion
  • User-specific translations - Personalize based on user preferences
  • Translator access - Let translators update directly via Supabase dashboard
  • Real-time sync - Push translation updates to all users immediately
  • Version control - Track translation history with PostgreSQL

Project Setup

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  supabase_flutter: ^2.3.0
  shared_preferences: ^2.2.2
  connectivity_plus: ^5.0.2

flutter:
  generate: true

Database Schema

Create these tables in Supabase SQL Editor:

-- Languages table
CREATE TABLE languages (
  code TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  native_name TEXT NOT NULL,
  is_rtl BOOLEAN DEFAULT FALSE,
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Translations table
CREATE TABLE translations (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  key TEXT NOT NULL,
  language_code TEXT REFERENCES languages(code) ON DELETE CASCADE,
  value TEXT NOT NULL,
  context TEXT,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  updated_by UUID REFERENCES auth.users(id),
  UNIQUE(key, language_code)
);

-- Translation versions for history
CREATE TABLE translation_versions (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  translation_id UUID REFERENCES translations(id) ON DELETE CASCADE,
  value TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  created_by UUID REFERENCES auth.users(id)
);

-- Indexes for performance
CREATE INDEX idx_translations_language ON translations(language_code);
CREATE INDEX idx_translations_key ON translations(key);

-- Enable Row Level Security
ALTER TABLE languages ENABLE ROW LEVEL SECURITY;
ALTER TABLE translations ENABLE ROW LEVEL SECURITY;
ALTER TABLE translation_versions ENABLE ROW LEVEL SECURITY;

-- Policies for public read access
CREATE POLICY "Languages are publicly readable"
  ON languages FOR SELECT
  USING (is_active = TRUE);

CREATE POLICY "Translations are publicly readable"
  ON translations FOR SELECT
  USING (TRUE);

-- Insert default languages
INSERT INTO languages (code, name, native_name, is_rtl) VALUES
  ('en', 'English', 'English', FALSE),
  ('es', 'Spanish', 'Espanol', FALSE),
  ('de', 'German', 'Deutsch', FALSE),
  ('fr', 'French', 'Francais', FALSE),
  ('ar', 'Arabic', 'العربية', TRUE),
  ('ja', 'Japanese', '日本語', FALSE),
  ('zh', 'Chinese', '中文', FALSE);

Supabase Service

// lib/services/supabase_localization_service.dart
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';

class SupabaseLocalizationService {
  final SupabaseClient _client;
  final SharedPreferences _prefs;

  static const String _cacheKeyPrefix = 'translations_';
  static const String _lastSyncKey = 'translations_last_sync';
  static const Duration _cacheExpiry = Duration(hours: 1);

  SupabaseLocalizationService(this._client, this._prefs);

  /// Get all translations for a language
  Future<Map<String, String>> getTranslations(String languageCode) async {
    // Try cache first
    final cached = _getCachedTranslations(languageCode);
    if (cached != null && !_isCacheExpired()) {
      return cached;
    }

    // Fetch from Supabase
    try {
      final response = await _client
          .from('translations')
          .select('key, value')
          .eq('language_code', languageCode);

      final translations = <String, String>{};
      for (final row in response as List) {
        translations[row['key'] as String] = row['value'] as String;
      }

      // Cache the results
      await _cacheTranslations(languageCode, translations);

      return translations;
    } catch (e) {
      // Return cached version on error
      return cached ?? {};
    }
  }

  /// Get available languages
  Future<List<LanguageInfo>> getLanguages() async {
    final response = await _client
        .from('languages')
        .select()
        .eq('is_active', true)
        .order('name');

    return (response as List)
        .map((row) => LanguageInfo.fromJson(row))
        .toList();
  }

  /// Subscribe to real-time translation updates
  RealtimeChannel subscribeToUpdates(
    String languageCode,
    void Function(Map<String, String>) onUpdate,
  ) {
    return _client
        .channel('translations:$languageCode')
        .onPostgresChanges(
          event: PostgresChangeEvent.all,
          schema: 'public',
          table: 'translations',
          filter: PostgresChangeFilter(
            type: PostgresChangeFilterType.eq,
            column: 'language_code',
            value: languageCode,
          ),
          callback: (payload) async {
            // Refresh all translations on any change
            final translations = await getTranslations(languageCode);
            onUpdate(translations);
          },
        )
        .subscribe();
  }

  /// Cache translations locally
  Future<void> _cacheTranslations(
    String languageCode,
    Map<String, String> translations,
  ) async {
    await _prefs.setString(
      '$_cacheKeyPrefix$languageCode',
      jsonEncode(translations),
    );
    await _prefs.setInt(_lastSyncKey, DateTime.now().millisecondsSinceEpoch);
  }

  /// Get cached translations
  Map<String, String>? _getCachedTranslations(String languageCode) {
    final cached = _prefs.getString('$_cacheKeyPrefix$languageCode');
    if (cached == null) return null;

    final decoded = jsonDecode(cached) as Map<String, dynamic>;
    return decoded.map((k, v) => MapEntry(k, v as String));
  }

  /// Check if cache is expired
  bool _isCacheExpired() {
    final lastSync = _prefs.getInt(_lastSyncKey);
    if (lastSync == null) return true;

    final lastSyncTime = DateTime.fromMillisecondsSinceEpoch(lastSync);
    return DateTime.now().difference(lastSyncTime) > _cacheExpiry;
  }

  /// Force refresh translations
  Future<Map<String, String>> refreshTranslations(String languageCode) async {
    await _prefs.remove('$_cacheKeyPrefix$languageCode');
    return getTranslations(languageCode);
  }
}

class LanguageInfo {
  final String code;
  final String name;
  final String nativeName;
  final bool isRtl;

  LanguageInfo({
    required this.code,
    required this.name,
    required this.nativeName,
    required this.isRtl,
  });

  factory LanguageInfo.fromJson(Map<String, dynamic> json) {
    return LanguageInfo(
      code: json['code'] as String,
      name: json['name'] as String,
      nativeName: json['native_name'] as String,
      isRtl: json['is_rtl'] as bool? ?? false,
    );
  }
}

Custom Localization Delegate

// lib/l10n/supabase_localizations.dart
import 'package:flutter/material.dart';
import '../services/supabase_localization_service.dart';

class SupabaseLocalizations {
  final Map<String, String> _translations;
  final String _languageCode;

  SupabaseLocalizations(this._translations, this._languageCode);

  static SupabaseLocalizations? of(BuildContext context) {
    return Localizations.of<SupabaseLocalizations>(
      context,
      SupabaseLocalizations,
    );
  }

  /// Get translation by key with optional fallback
  String translate(String key, {String? fallback}) {
    return _translations[key] ?? fallback ?? key;
  }

  /// Get translation with parameter substitution
  String translateWithParams(
    String key,
    Map<String, String> params, {
    String? fallback,
  }) {
    var text = translate(key, fallback: fallback);

    params.forEach((param, value) {
      text = text.replaceAll('{$param}', value);
    });

    return text;
  }

  /// Get plural translation
  String plural(
    String key,
    int count, {
    String? zero,
    String? one,
    String? two,
    String? few,
    String? many,
    String? other,
  }) {
    final pluralKey = _getPluralKey(key, count);
    return translateWithParams(
      pluralKey,
      {'count': count.toString()},
      fallback: other ?? key,
    );
  }

  String _getPluralKey(String key, int count) {
    // ICU plural rules (simplified)
    if (count == 0) return '${key}_zero';
    if (count == 1) return '${key}_one';
    if (count == 2) return '${key}_two';
    if (count >= 3 && count <= 10) return '${key}_few';
    if (count >= 11 && count <= 99) return '${key}_many';
    return '${key}_other';
  }
}

class SupabaseLocalizationsDelegate
    extends LocalizationsDelegate<SupabaseLocalizations> {
  final SupabaseLocalizationService _service;
  final List<String> supportedLanguageCodes;

  SupabaseLocalizationsDelegate(
    this._service, {
    this.supportedLanguageCodes = const ['en', 'es', 'de', 'fr', 'ar', 'ja'],
  });

  @override
  bool isSupported(Locale locale) {
    return supportedLanguageCodes.contains(locale.languageCode);
  }

  @override
  Future<SupabaseLocalizations> load(Locale locale) async {
    final translations = await _service.getTranslations(locale.languageCode);
    return SupabaseLocalizations(translations, locale.languageCode);
  }

  @override
  bool shouldReload(SupabaseLocalizationsDelegate old) => false;
}

Locale Provider with Supabase

// lib/providers/supabase_locale_provider.dart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../services/supabase_localization_service.dart';

class SupabaseLocaleProvider extends ChangeNotifier {
  final SupabaseLocalizationService _service;

  Locale _locale = const Locale('en');
  Map<String, String> _translations = {};
  List<LanguageInfo> _availableLanguages = [];
  bool _isLoading = true;
  RealtimeChannel? _subscription;

  SupabaseLocaleProvider(this._service);

  Locale get locale => _locale;
  Map<String, String> get translations => _translations;
  List<LanguageInfo> get availableLanguages => _availableLanguages;
  bool get isLoading => _isLoading;
  bool get isRtl => _availableLanguages
      .where((l) => l.code == _locale.languageCode)
      .firstOrNull
      ?.isRtl ?? false;

  /// Initialize with user's preferred language
  Future<void> initialize([String? preferredLanguage]) async {
    _isLoading = true;
    notifyListeners();

    // Load available languages
    _availableLanguages = await _service.getLanguages();

    // Determine initial language
    final langCode = preferredLanguage ??
        WidgetsBinding.instance.platformDispatcher.locale.languageCode;

    // Use preferred if available, otherwise fall back to English
    final isSupported = _availableLanguages.any((l) => l.code == langCode);
    _locale = Locale(isSupported ? langCode : 'en');

    // Load translations
    _translations = await _service.getTranslations(_locale.languageCode);

    // Subscribe to real-time updates
    _subscribeToUpdates();

    _isLoading = false;
    notifyListeners();
  }

  /// Change language
  Future<void> setLocale(Locale locale) async {
    if (_locale == locale) return;

    _isLoading = true;
    notifyListeners();

    // Unsubscribe from old language updates
    await _subscription?.unsubscribe();

    _locale = locale;
    _translations = await _service.getTranslations(locale.languageCode);

    // Subscribe to new language updates
    _subscribeToUpdates();

    _isLoading = false;
    notifyListeners();
  }

  /// Subscribe to real-time translation updates
  void _subscribeToUpdates() {
    _subscription = _service.subscribeToUpdates(
      _locale.languageCode,
      (updatedTranslations) {
        _translations = updatedTranslations;
        notifyListeners();
      },
    );
  }

  /// Force refresh translations from server
  Future<void> refresh() async {
    _translations = await _service.refreshTranslations(_locale.languageCode);
    notifyListeners();
  }

  @override
  void dispose() {
    _subscription?.unsubscribe();
    super.dispose();
  }
}

App Integration

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'services/supabase_localization_service.dart';
import 'providers/supabase_locale_provider.dart';
import 'l10n/supabase_localizations.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Supabase
  await Supabase.initialize(
    url: 'YOUR_SUPABASE_URL',
    anonKey: 'YOUR_SUPABASE_ANON_KEY',
  );

  final prefs = await SharedPreferences.getInstance();
  final service = SupabaseLocalizationService(
    Supabase.instance.client,
    prefs,
  );

  final localeProvider = SupabaseLocaleProvider(service);
  await localeProvider.initialize();

  runApp(
    ChangeNotifierProvider.value(
      value: localeProvider,
      child: MyApp(service: service),
    ),
  );
}

class MyApp extends StatelessWidget {
  final SupabaseLocalizationService service;

  const MyApp({super.key, required this.service});

  @override
  Widget build(BuildContext context) {
    final localeProvider = context.watch<SupabaseLocaleProvider>();

    if (localeProvider.isLoading) {
      return const MaterialApp(
        home: Scaffold(
          body: Center(child: CircularProgressIndicator()),
        ),
      );
    }

    return MaterialApp(
      locale: localeProvider.locale,
      supportedLocales: localeProvider.availableLanguages
          .map((l) => Locale(l.code))
          .toList(),
      localizationsDelegates: [
        SupabaseLocalizationsDelegate(service),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      builder: (context, child) {
        return Directionality(
          textDirection: localeProvider.isRtl
              ? TextDirection.rtl
              : TextDirection.ltr,
          child: child!,
        );
      },
      home: const HomePage(),
    );
  }
}

Using Translations in Widgets

// lib/screens/home_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/supabase_localizations.dart';
import '../providers/supabase_locale_provider.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = SupabaseLocalizations.of(context)!;
    final localeProvider = context.watch<SupabaseLocaleProvider>();

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.translate('app_title', fallback: 'My App')),
        actions: [
          // Language selector
          PopupMenuButton<String>(
            icon: const Icon(Icons.language),
            onSelected: (code) {
              localeProvider.setLocale(Locale(code));
            },
            itemBuilder: (context) {
              return localeProvider.availableLanguages.map((lang) {
                return PopupMenuItem(
                  value: lang.code,
                  child: Row(
                    children: [
                      Text(lang.nativeName),
                      if (lang.code == localeProvider.locale.languageCode)
                        const Padding(
                          padding: EdgeInsets.only(left: 8),
                          child: Icon(Icons.check, size: 16),
                        ),
                    ],
                  ),
                );
              }).toList();
            },
          ),
          // Refresh button
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () => localeProvider.refresh(),
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // Simple translation
          Text(
            l10n.translate('welcome_message', fallback: 'Welcome!'),
            style: Theme.of(context).textTheme.headlineMedium,
          ),

          const SizedBox(height: 16),

          // Translation with parameters
          Text(
            l10n.translateWithParams(
              'greeting',
              {'name': 'Flutter Developer'},
              fallback: 'Hello, {name}!',
            ),
          ),

          const SizedBox(height: 16),

          // Plural translation
          _buildItemCount(context, l10n, 0),
          _buildItemCount(context, l10n, 1),
          _buildItemCount(context, l10n, 5),
          _buildItemCount(context, l10n, 100),
        ],
      ),
    );
  }

  Widget _buildItemCount(
    BuildContext context,
    SupabaseLocalizations l10n,
    int count,
  ) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Text(
        l10n.plural('items_count', count),
      ),
    );
  }
}

Admin Panel for Translators

Create an Edge Function for translator access:

// supabase/functions/update-translation/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req) => {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  // Verify user is authenticated and has translator role
  const authHeader = req.headers.get('Authorization')
  if (!authHeader) {
    return new Response('Unauthorized', { status: 401 })
  }

  const { data: { user }, error: authError } = await supabase.auth.getUser(
    authHeader.replace('Bearer ', '')
  )

  if (authError || !user) {
    return new Response('Unauthorized', { status: 401 })
  }

  // Check translator role
  const { data: profile } = await supabase
    .from('profiles')
    .select('role')
    .eq('id', user.id)
    .single()

  if (profile?.role !== 'translator' && profile?.role !== 'admin') {
    return new Response('Forbidden', { status: 403 })
  }

  // Update translation
  const { key, language_code, value } = await req.json()

  // Save current version to history
  const { data: current } = await supabase
    .from('translations')
    .select('id, value')
    .eq('key', key)
    .eq('language_code', language_code)
    .single()

  if (current) {
    await supabase.from('translation_versions').insert({
      translation_id: current.id,
      value: current.value,
      created_by: user.id,
    })
  }

  // Upsert new translation
  const { error } = await supabase
    .from('translations')
    .upsert({
      key,
      language_code,
      value,
      updated_at: new Date().toISOString(),
      updated_by: user.id,
    })

  if (error) {
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
    })
  }

  return new Response(JSON.stringify({ success: true }), {
    headers: { 'Content-Type': 'application/json' },
  })
})

Seeding Initial Translations

-- Insert sample translations
INSERT INTO translations (key, language_code, value) VALUES
  -- English
  ('app_title', 'en', 'My Awesome App'),
  ('welcome_message', 'en', 'Welcome to our app!'),
  ('greeting', 'en', 'Hello, {name}!'),
  ('items_count_zero', 'en', 'No items'),
  ('items_count_one', 'en', '1 item'),
  ('items_count_other', 'en', '{count} items'),

  -- Spanish
  ('app_title', 'es', 'Mi Aplicacion Increible'),
  ('welcome_message', 'es', 'Bienvenido a nuestra aplicacion!'),
  ('greeting', 'es', 'Hola, {name}!'),
  ('items_count_zero', 'es', 'Sin articulos'),
  ('items_count_one', 'es', '1 articulo'),
  ('items_count_other', 'es', '{count} articulos'),

  -- German
  ('app_title', 'de', 'Meine Tolle App'),
  ('welcome_message', 'de', 'Willkommen in unserer App!'),
  ('greeting', 'de', 'Hallo, {name}!'),
  ('items_count_zero', 'de', 'Keine Artikel'),
  ('items_count_one', 'de', '1 Artikel'),
  ('items_count_other', 'de', '{count} Artikel');

Offline Support

// lib/services/offline_supabase_service.dart
import 'package:connectivity_plus/connectivity_plus.dart';

class OfflineAwareLocalizationService extends SupabaseLocalizationService {
  final Connectivity _connectivity = Connectivity();

  OfflineAwareLocalizationService(super.client, super.prefs);

  @override
  Future<Map<String, String>> getTranslations(String languageCode) async {
    final connectivityResult = await _connectivity.checkConnectivity();

    if (connectivityResult == ConnectivityResult.none) {
      // Offline - use cache only
      final cached = _getCachedTranslations(languageCode);
      if (cached != null) return cached;

      // No cache - return bundled fallback
      return _getBundledTranslations(languageCode);
    }

    // Online - normal flow
    return super.getTranslations(languageCode);
  }

  Map<String, String> _getBundledTranslations(String languageCode) {
    // Fallback translations bundled with app
    const bundled = {
      'en': {
        'app_title': 'My App',
        'welcome_message': 'Welcome!',
        'offline_notice': 'You are offline. Some translations may be outdated.',
      },
      'es': {
        'app_title': 'Mi App',
        'welcome_message': 'Bienvenido!',
        'offline_notice': 'Estas sin conexion. Algunas traducciones pueden estar desactualizadas.',
      },
    };

    return bundled[languageCode] ?? bundled['en']!;
  }
}

Testing

// test/supabase_localization_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

class MockSupabaseClient extends Mock implements SupabaseClient {}
class MockSharedPreferences extends Mock implements SharedPreferences {}

void main() {
  group('SupabaseLocalizationService', () {
    late MockSupabaseClient mockClient;
    late MockSharedPreferences mockPrefs;
    late SupabaseLocalizationService service;

    setUp(() {
      mockClient = MockSupabaseClient();
      mockPrefs = MockSharedPreferences();
      service = SupabaseLocalizationService(mockClient, mockPrefs);
    });

    test('returns cached translations when available', () async {
      when(mockPrefs.getString('translations_en'))
          .thenReturn('{"hello": "Hello"}');
      when(mockPrefs.getInt('translations_last_sync'))
          .thenReturn(DateTime.now().millisecondsSinceEpoch);

      final translations = await service.getTranslations('en');

      expect(translations['hello'], 'Hello');
      verifyNever(mockClient.from(any));
    });

    test('fetches from Supabase when cache expired', () async {
      when(mockPrefs.getString('translations_en')).thenReturn(null);
      when(mockPrefs.getInt('translations_last_sync')).thenReturn(0);

      // Mock Supabase response...
    });
  });
}

Best Practices

1. Always Have Fallbacks

l10n.translate('key', fallback: 'Fallback text')

2. Bundle Critical Translations

Keep essential UI strings bundled with the app for offline scenarios.

3. Use Row Level Security

Ensure only authorized users can modify translations:

CREATE POLICY "Only translators can update"
  ON translations FOR UPDATE
  USING (auth.jwt() ->> 'role' IN ('translator', 'admin'));

4. Monitor Real-time Connections

Handle subscription errors gracefully:

_subscription?.onError((error) {
  print('Real-time subscription error: $error');
  // Fall back to polling
});

5. Version Your Translations

Use the translation_versions table to track changes and enable rollbacks.

Summary

Supabase provides a powerful backend for Flutter remote localization:

  1. PostgreSQL for reliable translation storage
  2. Real-time subscriptions for instant updates
  3. Row Level Security for translator access control
  4. Edge Functions for custom translation workflows
  5. Offline support with local caching

This approach gives you the flexibility to update translations instantly while maintaining the reliability of local fallbacks.

Related Resources