← Back to Blog

Flutter GraphQL Localization: Fetch Translations from Your API

fluttergraphqlapilocalizationremotereal-timecaching

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: