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:
- PostgreSQL for reliable translation storage
- Real-time subscriptions for instant updates
- Row Level Security for translator access control
- Edge Functions for custom translation workflows
- Offline support with local caching
This approach gives you the flexibility to update translations instantly while maintaining the reliability of local fallbacks.