Flutter GraphQL Localization: Fetch Translations from Your API
Need to load translations dynamically from a GraphQL API? This guide shows you how to integrate GraphQL with Flutter localization for real-time, server-driven translations.
Why GraphQL for Translations?
GraphQL offers unique advantages for translation management:
| Feature | Benefit |
|---|---|
| Query exactly what you need | Fetch only translations for current screen |
| Single request | Get multiple locales in one query |
| Real-time updates | Subscriptions for live translation changes |
| Type safety | Schema validation prevents errors |
| Caching | Built-in normalized cache |
Setting Up GraphQL Client
Step 1: Add Dependencies
# pubspec.yaml
dependencies:
graphql_flutter: ^5.1.0
flutter_localizations:
sdk: flutter
Step 2: Define GraphQL Schema
# schema.graphql
type Translation {
key: String!
value: String!
locale: String!
description: String
updatedAt: DateTime
}
type TranslationBundle {
locale: String!
translations: [Translation!]!
version: Int!
}
type Query {
translations(locale: String!, keys: [String!]): TranslationBundle!
allTranslations(locale: String!): TranslationBundle!
availableLocales: [LocaleInfo!]!
}
type LocaleInfo {
code: String!
name: String!
nativeName: String!
isRTL: Boolean!
}
type Subscription {
translationUpdated(locale: String!): Translation!
}
Step 3: Configure GraphQL Client
import 'package:graphql_flutter/graphql_flutter.dart';
class GraphQLConfig {
static final HttpLink _httpLink = HttpLink(
'https://api.yourapp.com/graphql',
);
static final AuthLink _authLink = AuthLink(
getToken: () async {
final token = await AuthService.getToken();
return token != null ? 'Bearer $token' : null;
},
);
static final Link _link = _authLink.concat(_httpLink);
static final GraphQLClient client = GraphQLClient(
link: _link,
cache: GraphQLCache(
store: HiveStore(),
),
);
static ValueNotifier<GraphQLClient> get clientNotifier =>
ValueNotifier(client);
}
// Wrap app with GraphQL provider
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GraphQLProvider(
client: GraphQLConfig.clientNotifier,
child: MaterialApp(
// ...
),
);
}
}
Fetching Translations
Basic Translation Query
const String getTranslationsQuery = r'''
query GetTranslations($locale: String!) {
allTranslations(locale: $locale) {
locale
version
translations {
key
value
description
}
}
}
''';
class TranslationService {
final GraphQLClient _client;
TranslationService(this._client);
Future<Map<String, String>> fetchTranslations(String locale) async {
final result = await _client.query(
QueryOptions(
document: gql(getTranslationsQuery),
variables: {'locale': locale},
fetchPolicy: FetchPolicy.cacheFirst,
),
);
if (result.hasException) {
throw result.exception!;
}
final bundle = result.data!['allTranslations'];
final translations = bundle['translations'] as List;
return Map.fromEntries(
translations.map((t) => MapEntry(t['key'] as String, t['value'] as String)),
);
}
}
Fetch Specific Keys Only
const String getSpecificTranslationsQuery = r'''
query GetTranslations($locale: String!, $keys: [String!]!) {
translations(locale: $locale, keys: $keys) {
locale
translations {
key
value
}
}
}
''';
// Fetch only what the current screen needs
Future<Map<String, String>> fetchScreenTranslations(
String locale,
List<String> keys,
) async {
final result = await _client.query(
QueryOptions(
document: gql(getSpecificTranslationsQuery),
variables: {
'locale': locale,
'keys': keys,
},
),
);
// ... parse result
}
// Usage
final homeTranslations = await fetchScreenTranslations('en', [
'home_title',
'home_welcome',
'home_cta_button',
]);
Custom Localization Delegate
GraphQL Localization Delegate
class GraphQLLocalizationsDelegate
extends LocalizationsDelegate<GraphQLLocalizations> {
final TranslationService service;
Map<String, Map<String, String>> _cache = {};
GraphQLLocalizationsDelegate(this.service);
@override
bool isSupported(Locale locale) => true;
@override
Future<GraphQLLocalizations> load(Locale locale) async {
final localeCode = locale.languageCode;
// Check cache first
if (!_cache.containsKey(localeCode)) {
try {
_cache[localeCode] = await service.fetchTranslations(localeCode);
} catch (e) {
// Fallback to English on error
_cache[localeCode] = _cache['en'] ?? {};
debugPrint('Failed to load $localeCode translations: $e');
}
}
return GraphQLLocalizations(_cache[localeCode]!, locale);
}
@override
bool shouldReload(GraphQLLocalizationsDelegate old) => false;
/// Force reload translations
void clearCache() {
_cache.clear();
}
}
class GraphQLLocalizations {
final Map<String, String> _translations;
final Locale locale;
GraphQLLocalizations(this._translations, this.locale);
static GraphQLLocalizations of(BuildContext context) {
return Localizations.of<GraphQLLocalizations>(
context,
GraphQLLocalizations,
)!;
}
String translate(String key, [Map<String, dynamic>? params]) {
String value = _translations[key] ?? key;
if (params != null) {
params.forEach((paramKey, paramValue) {
value = value.replaceAll('{$paramKey}', paramValue.toString());
});
}
return value;
}
// Convenience method
String tr(String key, [Map<String, dynamic>? params]) =>
translate(key, params);
}
Usage in App
class MyApp extends StatelessWidget {
final TranslationService translationService;
const MyApp({required this.translationService});
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: [
GraphQLLocalizationsDelegate(translationService),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('es'),
Locale('fr'),
],
home: const HomePage(),
);
}
}
// In widgets
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = GraphQLLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(l10n.tr('home_title')),
),
body: Text(l10n.tr('welcome_message', {'name': 'John'})),
);
}
}
Real-Time Translation Updates
GraphQL Subscriptions
const String translationSubscription = r'''
subscription OnTranslationUpdated($locale: String!) {
translationUpdated(locale: $locale) {
key
value
updatedAt
}
}
''';
class LiveTranslationService {
final GraphQLClient _client;
final Map<String, String> _translations = {};
final _controller = StreamController<Map<String, String>>.broadcast();
Stream<Map<String, String>> get translationStream => _controller.stream;
void subscribeToUpdates(String locale) {
final subscription = _client.subscribe(
SubscriptionOptions(
document: gql(translationSubscription),
variables: {'locale': locale},
),
);
subscription.listen((result) {
if (result.data != null) {
final updated = result.data!['translationUpdated'];
_translations[updated['key']] = updated['value'];
_controller.add(Map.from(_translations));
}
});
}
void dispose() {
_controller.close();
}
}
// Widget that updates in real-time
class LiveTranslatedText extends StatelessWidget {
final String translationKey;
final LiveTranslationService service;
const LiveTranslatedText({
required this.translationKey,
required this.service,
});
@override
Widget build(BuildContext context) {
return StreamBuilder<Map<String, String>>(
stream: service.translationStream,
builder: (context, snapshot) {
final translations = snapshot.data ?? {};
final text = translations[translationKey] ?? translationKey;
return Text(text);
},
);
}
}
Pagination and Lazy Loading
Paginated Translation Fetching
const String getPaginatedTranslationsQuery = r'''
query GetTranslations(
$locale: String!
$first: Int!
$after: String
) {
translations(locale: $locale, first: $first, after: $after) {
edges {
node {
key
value
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
''';
class PaginatedTranslationLoader {
final GraphQLClient _client;
final int _pageSize;
PaginatedTranslationLoader(this._client, {int pageSize = 100})
: _pageSize = pageSize;
Future<Map<String, String>> loadAllTranslations(String locale) async {
final allTranslations = <String, String>{};
String? cursor;
bool hasMore = true;
while (hasMore) {
final result = await _client.query(
QueryOptions(
document: gql(getPaginatedTranslationsQuery),
variables: {
'locale': locale,
'first': _pageSize,
'after': cursor,
},
),
);
final connection = result.data!['translations'];
final edges = connection['edges'] as List;
for (final edge in edges) {
final node = edge['node'];
allTranslations[node['key']] = node['value'];
}
final pageInfo = connection['pageInfo'];
hasMore = pageInfo['hasNextPage'];
cursor = pageInfo['endCursor'];
}
return allTranslations;
}
}
Screen-Based Loading
class ScreenTranslationLoader {
final GraphQLClient _client;
final Map<String, Set<String>> _screenKeys = {};
// Register keys needed for each screen
void registerScreen(String screenId, List<String> keys) {
_screenKeys[screenId] = keys.toSet();
}
Future<Map<String, String>> loadForScreen(
String screenId,
String locale,
) async {
final keys = _screenKeys[screenId];
if (keys == null || keys.isEmpty) {
return {};
}
final result = await _client.query(
QueryOptions(
document: gql(getSpecificTranslationsQuery),
variables: {
'locale': locale,
'keys': keys.toList(),
},
),
);
// Parse and return
final translations = result.data!['translations']['translations'] as List;
return Map.fromEntries(
translations.map((t) => MapEntry(t['key'], t['value'])),
);
}
}
// Usage
final loader = ScreenTranslationLoader(client);
// Register at app startup
loader.registerScreen('home', ['home_title', 'home_welcome', 'home_cta']);
loader.registerScreen('profile', ['profile_title', 'profile_edit', 'profile_logout']);
loader.registerScreen('settings', ['settings_title', 'settings_language', 'settings_theme']);
// Load when navigating
final homeTranslations = await loader.loadForScreen('home', 'en');
Caching Strategies
Optimistic Cache Updates
class CachedTranslationService {
final GraphQLClient _client;
final Box<String> _localCache; // Hive box
CachedTranslationService(this._client, this._localCache);
Future<Map<String, String>> getTranslations(String locale) async {
// Try local cache first
final cacheKey = 'translations_$locale';
final cached = _localCache.get(cacheKey);
if (cached != null) {
// Return cached while fetching fresh
_fetchAndUpdateCache(locale, cacheKey);
return Map<String, String>.from(jsonDecode(cached));
}
// No cache, fetch from server
return await _fetchAndUpdateCache(locale, cacheKey);
}
Future<Map<String, String>> _fetchAndUpdateCache(
String locale,
String cacheKey,
) async {
final result = await _client.query(
QueryOptions(
document: gql(getTranslationsQuery),
variables: {'locale': locale},
fetchPolicy: FetchPolicy.networkOnly,
),
);
if (!result.hasException) {
final translations = _parseTranslations(result.data!);
// Update local cache
await _localCache.put(cacheKey, jsonEncode(translations));
return translations;
}
throw result.exception!;
}
}
Version-Based Invalidation
const String getTranslationVersionQuery = r'''
query GetVersion($locale: String!) {
allTranslations(locale: $locale) {
version
}
}
''';
class VersionedTranslationCache {
final GraphQLClient _client;
final SharedPreferences _prefs;
Map<String, String>? _translations;
int _cachedVersion = 0;
Future<Map<String, String>> getTranslations(String locale) async {
final serverVersion = await _fetchVersion(locale);
if (serverVersion > _cachedVersion || _translations == null) {
_translations = await _fetchTranslations(locale);
_cachedVersion = serverVersion;
// Persist version
await _prefs.setInt('translation_version_$locale', serverVersion);
}
return _translations!;
}
Future<int> _fetchVersion(String locale) async {
final result = await _client.query(
QueryOptions(
document: gql(getTranslationVersionQuery),
variables: {'locale': locale},
fetchPolicy: FetchPolicy.networkOnly,
),
);
return result.data!['allTranslations']['version'] as int;
}
}
Error Handling
Graceful Degradation
class ResilientTranslationService {
final GraphQLClient _client;
final Map<String, String> _fallbackTranslations;
ResilientTranslationService(this._client, this._fallbackTranslations);
Future<Map<String, String>> fetchTranslations(String locale) async {
try {
final result = await _client.query(
QueryOptions(
document: gql(getTranslationsQuery),
variables: {'locale': locale},
),
).timeout(const Duration(seconds: 10));
if (result.hasException) {
_logError(result.exception!);
return _getFallback(locale);
}
return _parseTranslations(result.data!);
} on TimeoutException {
debugPrint('Translation fetch timed out for $locale');
return _getFallback(locale);
} catch (e) {
debugPrint('Translation fetch failed: $e');
return _getFallback(locale);
}
}
Map<String, String> _getFallback(String locale) {
// Try locale-specific fallback, then English, then hardcoded
return _fallbackTranslations;
}
}
Testing
import 'package:mockito/mockito.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
class MockGraphQLClient extends Mock implements GraphQLClient {}
void main() {
late MockGraphQLClient mockClient;
late TranslationService service;
setUp(() {
mockClient = MockGraphQLClient();
service = TranslationService(mockClient);
});
test('fetches translations successfully', () async {
when(mockClient.query(any)).thenAnswer((_) async => QueryResult(
data: {
'allTranslations': {
'locale': 'en',
'version': 1,
'translations': [
{'key': 'hello', 'value': 'Hello'},
{'key': 'goodbye', 'value': 'Goodbye'},
],
},
},
source: QueryResultSource.network,
));
final result = await service.fetchTranslations('en');
expect(result['hello'], 'Hello');
expect(result['goodbye'], 'Goodbye');
});
test('handles errors gracefully', () async {
when(mockClient.query(any)).thenAnswer((_) async => QueryResult(
exception: OperationException(
graphqlErrors: [GraphQLError(message: 'Server error')],
),
source: QueryResultSource.network,
));
expect(
() => service.fetchTranslations('en'),
throwsException,
);
});
}
Best Practices
1. Prefetch Common Translations
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Prefetch before app renders
await translationService.prefetch(['en', 'es']);
runApp(const MyApp());
}
2. Bundle Critical Translations
// Include essential translations in app bundle
const fallbackTranslations = {
'error_network': 'Network error. Please try again.',
'error_generic': 'Something went wrong.',
'button_retry': 'Retry',
};
3. Use Fragments for Reusability
fragment TranslationFields on Translation {
key
value
description
updatedAt
}
query GetTranslations($locale: String!) {
allTranslations(locale: $locale) {
translations {
...TranslationFields
}
}
}
Conclusion
GraphQL provides powerful capabilities for dynamic translation loading:
- Efficient queries - Fetch only what you need
- Real-time updates - Subscriptions for live changes
- Strong caching - Normalized cache out of the box
- Type safety - Schema validation
For managing your GraphQL translations, check out FlutterLocalisation.
Related Articles:
- Flutter Firebase Remote Translations
- Flutter Supabase Localization
- ARB Diff Tool - Compare translation files