← Back to Blog

Localize Flutter Backend & API Content (Beyond ARB)

flutterlocalizationi18narbbackend

Localize Flutter Backend & API Content (Beyond ARB)

Your app has two completely different translation problems, and mixing them up is why localization feels harder than it should. The first is your static UI shell: button labels, error messages, onboarding copy. That belongs in ARB files, compiled by gen-l10n into a type-safe AppLocalizations class. The second is runtime backend content: product names, CMS blocks, push-notification bodies, category labels that live in a database and change without an app release. Forcing that second kind into app_en.arb is a losing battle—you'd ship an app update every time marketing edits a headline.

This post draws a hard line between the two, then gives you a copy-paste pattern: AppLocalizations for the shell, and a custom AssetLoader plus a cache-and-fallback layer for API-driven text. That's how you cleanly translate backend content in Flutter without polluting your ARB files.

The line: compile-time strings vs. runtime strings

Ask one question of every string: does its English source live in my repo, or in a server I don't rebuild the app to change?

  • In the repo → ARB. "Save", "Are you sure you want to delete?", plurals, dates. These are known at build time. flutter gen-l10n turns app_en.arb and its siblings into getters like AppLocalizations.of(context)!.save. Type-safe, offline, zero network.
  • On the server → not ARB. A product titled "Wireless Earbuds" or a promo banner your CMS pushes at 9am. You cannot enumerate these at build time, so there is no ARB key for them. This is the flutter localize api response text problem, and it needs a runtime path.

Keep both. The mistake is picking one system for everything. Your shell should stay on gen-l10n because it gives you compile-time safety and works with no connection. Only the dynamic slice needs a remote path.

Static shell: keep gen-l10n exactly as-is

Nothing changes here. Your l10n.yaml (with synthetic-package: false and an explicit output-dir in the modern setup) generates AppLocalizations, and you wire the delegates in MaterialApp:

MaterialApp(
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  home: const HomePage(),
);

// anywhere in the tree
Text(AppLocalizations.of(context)!.checkout);

Edit these strings in your ARB editor, not at runtime. This layer is done—leave it alone.

Runtime path A: server returns already-localized fields

For per-entity content (products, articles, user-generated data), the cleanest approach is: the backend does the translating and returns the right language for the request. Send the locale, get back localized JSON.

final res = await dio.get(
  '/products/42',
  options: Options(headers: {'Accept-Language': locale.toLanguageTag()}),
);
final name = res.data['name']; // already "Écouteurs sans fil" for fr

If your API instead returns all translations inline, pick the field client-side with a fallback chain so a missing locale never renders blank:

String localizedField(Map<String, dynamic> field, Locale locale, {String fallback = 'en'}) {
  return (field[locale.languageCode] ??
          field[fallback] ??
          field.values.first ??
          '') as String;
}

// field == {"en": "Wireless Earbuds", "fr": "Écouteurs sans fil"}
final name = localizedField(product['name'], Localizations.localeOf(context));

This handles flutter dynamic string localization for data you can't possibly hardcode. No ARB entry is ever created for a product name—correctly so.

Runtime path B: remotely-managed UI strings via a custom AssetLoader

Sometimes you have UI strings—a seasonal banner, feature-flagged copy, legal text—that behave like the shell but must be editable without a release. This is flutter remote localization: real UI keys, but sourced over the network. The easy_localization package (currently 3.0.8) exposes an AssetLoader abstraction perfect for this. Its contract is one method:

abstract class AssetLoader {
  Future<Map<String, dynamic>> load(String path, Locale locale);
}

You can point it at your backend, but a naive network loader will freeze startup on a slow connection and break entirely offline. The fix is a cache-and-fallback loader: try the network, persist what you get, and fall back to the last good cache—then to a bundled asset—so the app always has strings.

import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'dart:io';

class CachedNetworkAssetLoader extends AssetLoader {
  const CachedNetworkAssetLoader(this.baseUrl);
  final String baseUrl; // e.g. https://cdn.example.com/i18n

  Future<File> _cacheFile(Locale locale) async {
    final dir = await getApplicationSupportDirectory();
    return File('${dir.path}/i18n_${locale.languageCode}.json');
  }

  @override
  Future<Map<String, dynamic>> load(String path, Locale locale) async {
    final lang = locale.languageCode;
    final cache = await _cacheFile(locale);

    // 1. Try the network with a tight timeout.
    try {
      final res = await http
          .get(Uri.parse('$baseUrl/$lang.json'))
          .timeout(const Duration(seconds: 4));
      if (res.statusCode == 200) {
        await cache.writeAsString(res.body); // persist for next launch
        return json.decode(res.body) as Map<String, dynamic>;
      }
    } catch (_) {
      // fall through to cache
    }

    // 2. Last known good cache.
    if (await cache.exists()) {
      return json.decode(await cache.readAsString()) as Map<String, dynamic>;
    }

    // 3. Bundled asset shipped with the app — guarantees non-empty UI.
    final bundled = await rootBundle.loadString('$path/$lang.json');
    return json.decode(bundled) as Map<String, dynamic>;
  }
}

Register it and use keys exactly like local ones:

await EasyLocalization.ensureInitialized();
runApp(
  EasyLocalization(
    supportedLocales: const [Locale('en'), Locale('fr')],
    fallbackLocale: const Locale('en'),
    path: 'assets/translations', // used by the bundled fallback
    assetLoader: const CachedNetworkAssetLoader('https://cdn.example.com/i18n'),
    child: const MyApp(),
  ),
);

// in a widget
Text('promo_banner'.tr());

Because load() runs on every locale change and at startup, cache-first-with-timeout keeps launches fast and makes offline launches work. Ship a bundled JSON per locale as the ultimate floor so a user on a plane still sees text.

Which path do I use?

Content System Why
Buttons, errors, plurals gen-l10n / ARB Compile-time, type-safe, offline
Product/CMS/user data Path A (localized fields) Unbounded keys, per-entity
Editable UI copy, banners Path B (custom loader) Real keys, release-free updates

If you catch yourself adding a new ARB key every time content changes, that content belongs in Path A or B. If you're deserializing an API map to render a fixed label, that label belongs in ARB.

Don't skip the fallback

The number-one production bug in flutter translate text not in arb setups is a blank screen when the translation service is unreachable. Every runtime string needs a fallback chain: requested locale → default locale → any available value → bundled asset. Cache aggressively, time out fast, and never let a network hiccup empty your UI. Path A's localizedField and Path B's three-tier load() both encode exactly this.

Try FlutterLocalisation free

Manage your static ARB shell in a real editor—side-by-side locales, plural and placeholder validation, and export straight to app_*.arb—so your compile-time strings stay clean while your backend handles the dynamic ones. Try FlutterLocalisation free, and see pricing when your team grows.