Flutter Monorepo Localization: Share ARBs Across Packages
You split the app into apps/app, packages/design_system, and packages/checkout. The design system ships its own ds_en.arb and ds_fr.arb. You launch in French: every string the app owns is French, and every string inside the design-system widgets is still English — or the widget throws Null check operator used on a null value before it paints.
Your ARB files are fine. The problem is that flutter gen-l10n produced two unrelated classes, and your MaterialApp only registered one delegate.
Why package strings never resolve
gen-l10n emits, per package, a localizations class, a private delegate, and a top-level lookup function. The generated getter looks like this (this is the shape from Flutter's own code templates):
// nullable-getter: true (the default)
static AppLocalizations? of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) =>
SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
@override
bool isSupported(Locale locale) => <String>['en', 'fr'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
The important part is Localizations.of<T>(context, T). The Localizations widget maintains a table of resource objects keyed by Type, one entry per delegate you passed to localizationsDelegates. A lookup for DesignSystemLocalizations only succeeds if a delegate whose type is DesignSystemLocalizations was in that list. Flutter's API docs are explicit: Localizations.of "returns null if no resources object of the given type exists within the given context."
So the app-level delegate can never resolve a package's keys. It isn't a fallback chain, and there is no merging of lookup tables — a package's strings live in a different class with a different Type key, full stop.
Step 1: give every package its own l10n.yaml and its own class name
One l10n.yaml per package, at that package's root. The critical option is output-class — leave it at the default and every package generates a class called AppLocalizations, which means as prefixes on every import and a coin flip about which one a shared file actually means.
# packages/design_system/l10n.yaml
arb-dir: lib/src/l10n
template-arb-file: ds_en.arb
output-dir: lib/src/l10n/generated
output-localization-file: ds_localizations.dart
output-class: DesignSystemLocalizations
Two notes that trip people coming from older blog posts:
- There is no
output-packageoption. It went away with the syntheticpackage:flutter_gen, which landed as a breaking change in 3.28.0-0.0.pre, shipped in stable 3.32.0, and was removed in the following stable.output-dirplus an export is the whole mechanism now. - Don't set
synthetic-package. On current stables the flag's own help text reads "DEPRECATED. This flag cannot be enabled and should be removed." Only addsynthetic-package: falseif you're pinned to an SDK older than 3.32.
You also don't need flutter: generate: true in the package's pubspec.yaml. That flag was tied to the synthetic package, and inside a pub workspace it's rejected outright unless you turn on explicit-package-dependencies — a fight worth skipping. Run flutter gen-l10n explicitly instead (see the melos script below). If you want a config generated for you, our l10n.yaml generator and the full l10n.yaml option reference cover every key.
Step 2: export the generated file from the package barrel
Generated code under lib/src/ is private to the package by convention, so re-export it:
// packages/design_system/lib/design_system.dart
export 'src/l10n/generated/ds_localizations.dart';
export 'src/widgets/retry_banner.dart';
That one line publishes DesignSystemLocalizations, its delegate, its supportedLocales, and the top-level lookupDesignSystemLocalizations(Locale) function to consumers.
Step 3: register every package delegate in the app
This is the actual fix. localizationsDelegates is a flat list, and each entry contributes exactly one type to the table:
import 'package:checkout/checkout.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/generated/app_localizations.dart';
MaterialApp(
localizationsDelegates: const <LocalizationsDelegate<Object>>[
AppLocalizations.delegate,
DesignSystemLocalizations.delegate,
CheckoutLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
);
Only the first delegate of a given type is loaded, so ordering matters only when you're deliberately overriding something like WidgetsLocalizations. Distinct package classes never collide.
A package with sub-packages should re-export its dependencies' delegates so the app doesn't have to know the whole tree:
// packages/checkout/lib/checkout.dart
static const List<LocalizationsDelegate<Object>> localizationsDelegates =
<LocalizationsDelegate<Object>>[
CheckoutLocalizations.delegate,
DesignSystemLocalizations.delegate,
];
Step 4: package widgets must read their own class
A widget that lives in design_system must never call AppLocalizations.of(context) — that would create a dependency from your design system back onto the app, which is exactly what you split the packages to avoid.
// packages/design_system/lib/src/widgets/retry_banner.dart
import 'package:flutter/material.dart';
import '../l10n/generated/ds_localizations.dart';
class RetryBanner extends StatelessWidget {
const RetryBanner({required this.onRetry, super.key});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final DesignSystemLocalizations l10n = context.ds;
return Row(
children: <Widget>[
Expanded(child: Text(l10n.somethingWentWrong)),
TextButton(onPressed: onRetry, child: Text(l10n.retry)),
],
);
}
}
The trap: a narrower package locale set returns null, not English
Here's the failure people misdiagnose. Localizations._loadAll filters delegates before loading them:
if (!types.contains(delegate.type) && delegate.isSupported(locale)) {
types.add(delegate.type);
delegates.add(delegate);
}
If the app supports en, fr, de but the design system only ships ds_en.arb and ds_fr.arb, then under de the design-system delegate's isSupported returns false, it's dropped from the table entirely, and its load() is never called. Localizations.of<DesignSystemLocalizations> returns null for that one package while the app's own strings resolve perfectly.
What you see next depends on nullable-getter:
nullable-getter: true(default) — the generatedof()isstatic DesignSystemLocalizations? of(...)with no!, so it simply hands younull. The crash happens later, at your call site.nullable-getter: false— the generator appends a!, producingreturn Localizations.of<DesignSystemLocalizations>(context, DesignSystemLocalizations)!;. That throwsNull check operator used on a null valueinsideof()itself.
Also note the generated isSupported matches on languageCode only. Locale('pt', 'BR') is supported by a package that ships ds_pt.arb; region is irrelevant to the check.
A one-line accessor makes the degradation explicit and survivable, using the public lookup function gen-l10n emits:
// packages/design_system/lib/src/l10n/context_extension.dart
extension DesignSystemL10n on BuildContext {
DesignSystemLocalizations get ds =>
Localizations.of<DesignSystemLocalizations>(this, DesignSystemLocalizations) ??
lookupDesignSystemLocalizations(const Locale('en'));
}
This calls Localizations.of directly, so it behaves the same whichever way nullable-getter is set — an unregistered or unsupported delegate degrades to English instead of crashing.
Keep supportedLocales in sync with a test, not a wiki page
Locale drift is the recurring cost of this setup. Assert it in CI:
test('every package covers every app locale', () {
final Set<String> app =
AppLocalizations.supportedLocales.map((Locale l) => l.languageCode).toSet();
for (final MapEntry<String, List<Locale>> pkg in <String, List<Locale>>{
'design_system': DesignSystemLocalizations.supportedLocales,
'checkout': CheckoutLocalizations.supportedLocales,
}.entries) {
final Set<String> missing =
app.difference(pkg.value.map((Locale l) => l.languageCode).toSet());
expect(missing, isEmpty, reason: '${pkg.key} is missing ARBs for: $missing');
}
});
Wire generation into melos
With a pub workspace (workspace: in the root pubspec.yaml, resolution: workspace in each member — Dart 3.6+), one melos script regenerates everything:
# melos.yaml
scripts:
l10n:
description: Regenerate localizations in every package that has an l10n.yaml
run: melos exec --file-exists=l10n.yaml -- flutter gen-l10n
Run melos run l10n in CI and fail the build on a dirty tree — that catches an ARB edit that was never regenerated.
Test package widgets in isolation
A design-system widget test doesn't need the app at all, which is the point:
await tester.pumpWidget(const MaterialApp(
localizationsDelegates: <LocalizationsDelegate<Object>>[
DesignSystemLocalizations.delegate,
],
supportedLocales: DesignSystemLocalizations.supportedLocales,
locale: Locale('fr'),
home: Scaffold(body: RetryBanner(onRetry: _noop)),
));
expect(find.text('Réessayer'), findsOneWidget);
If that test passes but the real app shows English, your app forgot the delegate. If it fails under a locale the app supports, the package is missing an ARB. Two different bugs, now distinguishable.
Keeping N sets of ARBs honest
Splitting into packages multiplies your ARB surface: three packages × six locales is eighteen files that all have to agree on locale coverage and ICU syntax. Plural categories are where this bites — a few/many category dropped from a package's Polish or Arabic file produces a runtime failure in only that package's widgets, in only that language.
FlutterLocalisation gives you an ARB editor over app_<locale>.arb files instead of raw JSON, translation management across every locale, and ICU plural-syntax validation that flags a locale missing a plural category the language actually requires. If you're debugging the errors above, our guide to common Flutter localization errors pairs well with this post.
Try FlutterLocalisation free and keep every package's ARBs in sync before the missing string ships.