Localize API Error Messages in Flutter with Dio
Your UI is flawless in French. Then checkout fails and the user reads "Payment method declined". That string never passed through your ARB files — it came straight from response.data['message'], and the server wrote it in English.
Two things are broken there, and only one of them is the backend's fault. Fixing both is what it takes to genuinely localize API error messages in Flutter.
The backend's message field is not display text
Treat any string the server sends as debug text, not display text. The reasons stack up fast:
- It is written in the server's default locale, or in whatever locale the server guessed.
- It changes without warning when a backend dev rewords a validation message — your translators never see it.
- It leaks internals (
"constraint users_email_key violated") and sometimes stack traces. - It has no plural rules, no gender, no ICU — nothing your ARB pipeline gives you.
So the contract you actually want from the backend is a stable machine-readable code:
{ "code": "card_declined", "message": "Card was declined by issuer", "status": 402 }
You render from code. The message goes to your logs and nowhere near a Text widget.
Send the resolved locale, not the device locale
Even with code mapping, you still want Accept-Language on every request — emails, PDFs, webhooks and server-rendered receipts are all generated backend-side and need to know the user's language.
The common mistake is reading the raw platform locale (PlatformDispatcher.instance.locale). A device set to fr-CA on an app that only ships fr and en will render fr in the UI, while the header says fr-CA — so the confirmation email arrives in Canadian French for a UI that isn't. Worse: a device set to ja on an app with no Japanese will render English UI and ask the server for Japanese copy.
Localizations.localeOf(context) returns the locale Flutter resolved against your supportedLocales list via basicLocaleListResolution — the same locale AppLocalizations is using. That's the one to send.
Interceptors don't have a BuildContext, so publish the resolved locale into a tiny holder:
// lib/core/app_locale.dart
import 'dart:ui';
/// Single mutable source of truth for the locale the UI is actually rendering.
class AppLocale {
AppLocale._();
static final AppLocale instance = AppLocale._();
Locale _current = const Locale('en');
Locale get current => _current;
void update(Locale locale) => _current = locale;
/// "fr", "pt-BR", "zh-Hans-CN" — Locale.toLanguageTag() emits a BCP 47 tag
/// with hyphens, which is exactly what Accept-Language expects.
String get languageTag => _current.toLanguageTag();
}
Feed it from inside the widget tree. MaterialApp.builder runs below the Localizations widget, so localeOf is valid there and re-runs whenever the locale changes:
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) {
AppLocale.instance.update(Localizations.localeOf(context));
return child!;
},
home: const HomePage(),
)
The Accept-Language interceptor
import 'package:dio/dio.dart';
class AcceptLanguageInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final tag = AppLocale.instance.languageTag;
// Quality values tell the server what to fall back to if it lacks `tag`.
options.headers['Accept-Language'] =
tag == 'en' ? 'en' : '$tag, ${tag.split('-').first};q=0.9, en;q=0.5';
handler.next(options);
}
}
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'))
..interceptors.addAll([
AcceptLanguageInterceptor(),
ErrorCodeInterceptor(),
]);
Two caveats worth ten minutes of your time:
- Attach this only to the
Dioinstance that talks to your API. Blanket-adding it to a client used for third-party hosts leaks user preferences. - If you cache responses (
dio_cache_interceptoror your own), the locale must be part of the cache key — otherwise the first French user poisons the cache for English ones.
Normalize every failure into a code
Transport failures need codes too. A timeout has no response.data at all, so if your UI only handles HTTP bodies it will show a blank error sheet when the user walks into a tunnel. Dio 5 exposes these as DioException with a DioExceptionType:
class ApiFailure implements Exception {
const ApiFailure({
required this.code,
this.status,
this.debugMessage,
this.reference,
});
final String code; // stable: 'card_declined', 'network_timeout'
final int? status;
final String? debugMessage; // server prose — logs only, never the UI
final String? reference; // request id, safe to show for support
}
class ErrorCodeInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
handler.reject(DioException(
requestOptions: err.requestOptions,
response: err.response,
type: err.type,
error: _toFailure(err), // now err.error is an ApiFailure everywhere
));
}
ApiFailure _toFailure(DioException err) {
switch (err.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const ApiFailure(code: 'network_timeout');
case DioExceptionType.connectionError:
return const ApiFailure(code: 'network_offline');
case DioExceptionType.badCertificate:
return const ApiFailure(code: 'bad_certificate');
case DioExceptionType.cancel:
return const ApiFailure(code: 'cancelled');
case DioExceptionType.badResponse:
case DioExceptionType.unknown:
break;
}
final data = err.response?.data;
final body = data is Map ? data : const {};
final rawCode = body['code'] ?? body['error_code'];
final rawMessage = body['message'];
final status = err.response?.statusCode;
return ApiFailure(
code: rawCode is String && rawCode.isNotEmpty
? rawCode
: 'http_${status ?? 0}',
status: status,
debugMessage: rawMessage is String ? rawMessage : null,
reference: err.response?.headers.value('x-request-id'),
);
}
}
Note the http_401 / http_500 synthesis: legacy endpoints that return no code still get something stable to map against.
Give error codes their own ARB namespace
Keep them prefixed so translators can see the whole error surface in one filtered view, and so nothing collides with product copy. Keys become Dart getters, so stick to camelCase.
{
"errorCardDeclined": "Votre carte a été refusée par votre banque.",
"@errorCardDeclined": { "description": "Server error code: card_declined" },
"errorEmailTaken": "Cette adresse e-mail est déjà utilisée.",
"@errorEmailTaken": { "description": "Server error code: email_taken" },
"errorNetworkOffline": "Aucune connexion Internet. Vérifiez votre réseau.",
"@errorNetworkOffline": { "description": "Dio connectionError" },
"errorUnknown": "Une erreur est survenue. Veuillez réessayer.",
"@errorUnknown": { "description": "Fallback for any unmapped error code" },
"errorUnknownWithReference": "Une erreur est survenue (réf. {reference}).",
"@errorUnknownWithReference": {
"description": "Fallback including a support reference id",
"placeholders": { "reference": { "type": "String", "example": "req_8f21c" } }
}
}
Put the server code in the description — it's the only context a translator gets, and "Server error code: card_declined" prevents a guess like "card refused by the app".
Map codes to strings (gen-l10n has no dynamic lookup)
This is the part people try to be clever about. flutter gen-l10n generates one getter per key — there is no t['errorCardDeclined'] and no built-in lookup by string. Write the switch:
import 'package:your_app/l10n/app_localizations.dart';
String localizeFailure(AppLocalizations t, ApiFailure f) {
switch (f.code) {
case 'card_declined': return t.errorCardDeclined;
case 'insufficient_funds': return t.errorInsufficientFunds;
case 'email_taken': return t.errorEmailTaken;
case 'network_offline': return t.errorNetworkOffline;
case 'network_timeout': return t.errorNetworkTimeout;
case 'http_401': return t.errorSessionExpired;
case 'http_429': return t.errorTooManyRequests;
}
final ref = f.reference;
return ref == null ? t.errorUnknown : t.errorUnknownWithReference(ref);
}
The switch is a feature, not a workaround: it's an allow-list. When the backend ships a brand-new code next sprint, your French users get polished generic copy instead of an English sentence — and the compiler still catches a deleted ARB key at build time, which a dynamic string lookup never would.
(If you're on Flutter 3.32 or newer, remember the synthetic package:flutter_gen is gone — generated localizations live in your source tree, typically lib/l10n/app_localizations.dart. Import from there.)
The fallback must never show a raw code
Two rules for the tail end:
- Never render the code itself.
card_declinedin a snackbar is a bug report, not UX.errorUnknownis the floor. - Log what you hid. Send
f.code,f.statusandf.debugMessageto Sentry/Crashlytics on every fallback hit. A spike inerrorUnknownis exactly the signal that tells you which code to add to the switch and to your ARB files this week.
final messenger = ScaffoldMessenger.of(context);
try {
await api.pay();
} on DioException catch (e) {
final failure = e.error is ApiFailure
? e.error as ApiFailure
: const ApiFailure(code: 'unknown');
reportToCrashlytics(failure); // code + debugMessage, not shown to the user
messenger.showSnackBar(
SnackBar(content: Text(localizeFailure(AppLocalizations.of(context)!, failure))),
);
}
Ship checklist
Accept-Languagederived fromLocalizations.localeOf(context).toLanguageTag(), not the device locale.- Header attached only to your own API client; locale included in any cache key.
- Every
DioExceptionTypemaps to a code — offline and timeout included. - Every error code has a key in every ARB file, not just
app_en.arb. errorUnknownexists, is translated, and is the only thing an unmapped code can produce.- Error copy reviewed by a human translator with the code in the
description.
The last two bullets are where error namespaces quietly rot: nobody notices a missing errorCardDeclined in app_de.arb until a German user hits a declined card in production.
Try FlutterLocalisation free
FlutterLocalisation is an ARB editor and translation-management platform built for exactly this: edit app_<locale>.arb files in a UI instead of hand-patching JSON, see at a glance which locales are missing your new error* keys, and catch ICU plural categories a language actually needs before they ship. Browse the features, check the pricing, and start on the free tier — then go delete that Text(response.data['message']).
More on locales: how to get the current locale in Flutter and fixing common Flutter localization errors.