Split Flutter ARB Files Per Feature (No Merge Conflicts)
If your team is more than two people, you already know the pain: app_en.arb is 3,000 lines, every feature branch touches it, and every PR ends in a JSON merge conflict. So someone tries the "obvious" fix—one AppLocalizations per feature, multiple delegates—and hits a wall: adding multiple delegates of the same type silently does nothing.
This post shows a copy-paste setup that splits ARB by feature into folders (auth, checkout, settings), keeps one AppLocalizations class and one delegate, and merges everything at build time with a tiny Dart tool. No multiple_localization package, no fragile bash scripts.
Why "one delegate per feature" doesn't work
The instinct is to generate AuthLocalizations, CheckoutLocalizations, etc., and register each delegate:
localizationsDelegates: [
AuthLocalizations.delegate,
CheckoutLocalizations.delegate, // 'multiple delegates' trap
...
],
Two things break here. First, per Flutter's own docs, only the first delegate of each LocalizationsDelegate.type is used—if your generated classes collide on type, the rest are ignored. Second, under the hood intl only honors the first initializeMessages() call per locale, so a second message set is dropped. That's the real reason multiple_localization exists as a workaround. We're going to avoid the whole category of problem instead.
The key realization: the merge conflict is a source-file problem, not a runtime problem. flutter gen-l10n is happy to consume one merged app_en.arb. We just need each team to edit their own file and let the build produce the canonical one.
The folder structure
Keep authored, per-feature ARB files in subfolders. The merged app_<locale>.arb files are build artifacts—generated, never hand-edited, and git-ignored so they can never conflict.
lib/
l10n/
features/
auth/
auth_en.arb
auth_es.arb
checkout/
checkout_en.arb
checkout_es.arb
settings/
settings_en.arb
settings_es.arb
app_en.arb # generated (git-ignored)
app_es.arb # generated (git-ignored)
gen/ # generated Dart output
A feature file is just a normal ARB scoped to that feature:
{
"@@locale": "en",
"authSignIn": "Sign in",
"@authSignIn": { "description": "Auth screen primary button" },
"authForgotPassword": "Forgot password?"
}
Because auth_en.arb and checkout_en.arb live in different files, two teams editing translations the same week never touch the same lines. Conflicts disappear at the source.
The build-time merge tool (pure Dart, cross-platform)
This ~40-line tool concatenates every *_<locale>.arb under features/ into lib/l10n/app_<locale>.arb, carrying over @-metadata, and fails loudly on duplicate keys so two features can't silently clobber each other.
// tool/merge_arb.dart
import 'dart:convert';
import 'dart:io';
void main() {
final featuresDir = Directory('lib/l10n/features');
final outDir = 'lib/l10n';
final merged = <String, Map<String, dynamic>>{}; // locale -> arb map
final owners = <String, Map<String, String>>{}; // locale -> key -> file
for (final e in featuresDir.listSync(recursive: true)) {
if (e is! File || !e.path.endsWith('.arb')) continue;
final name = e.uri.pathSegments.last; // e.g. auth_en.arb
final locale = name.substring(name.indexOf('_') + 1, name.length - 4);
final data = json.decode(e.readAsStringSync()) as Map<String, dynamic>;
final bucket = merged.putIfAbsent(locale, () => {'@@locale': locale});
final seen = owners.putIfAbsent(locale, () => {});
for (final entry in data.entries) {
if (entry.key == '@@locale') continue;
final key = entry.key.startsWith('@') ? entry.key.substring(1) : entry.key;
final prev = seen[key];
if (prev != null && prev != name) {
stderr.writeln('Duplicate key "$key" in $name and $prev ($locale)');
exit(1);
}
seen[key] = name;
bucket[entry.key] = entry.value;
}
}
const encoder = JsonEncoder.withIndent(' ');
for (final entry in merged.entries) {
final f = File('$outDir/app_${entry.key}.arb');
f.writeAsStringSync('${encoder.convert(entry.value)}\n');
stdout.writeln('Wrote ${f.path} (${entry.value.length - 1} keys)');
}
}
Run the merge, then generate:
dart run tool/merge_arb.dart && flutter gen-l10n
Unlike a bash loop that shells out to gen-l10n once per feature (producing N classes and N delegates), this produces one merged template and one AppLocalizations. It runs on macOS, Linux, Windows, and CI without modification.
The l10n.yaml that ties it together
Point gen-l10n at the merged files. Note: the synthetic package:flutter_gen is being removed (landed in 3.28, default in 3.32, gone in the next stable), so set synthetic-package: false and generate into your source tree.
# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n/gen
synthetic-package: false
nullable-getter: false
And in pubspec.yaml—without generate: true, Flutter ignores l10n.yaml entirely:
flutter:
generate: true
dependencies:
flutter_localizations:
sdk: flutter
intl: any
Wire it up in MaterialApp with a single delegate, exactly as the stock setup expects:
import 'package:your_app/l10n/gen/app_localizations.dart';
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
// ...
);
Access strings the normal way—AppLocalizations.of(context)!.authSignIn—because it's all one class.
Don't forget .gitignore and CI
The whole point is that humans never edit the merged files. Make that impossible to get wrong:
# .gitignore
lib/l10n/app_*.arb
lib/l10n/gen/
Then run the merge as the first step of your build so it's never "manual." In CI or a Makefile/Melos target:
# Makefile target or CI step
l10n:
dart run tool/merge_arb.dart
flutter gen-l10n
For local dev, add it to your run script or a melos command. Because the merged ARB is regenerated deterministically every build, a teammate pulling main always gets correct, conflict-free localizations.
Why this beats the alternatives
- vs.
multiple_localization: no extra dependency, no per-feature delegates, noinitializeMessagesordering surprises—you stay on the officialgen-l10npath. - vs. per-feature
gen-l10n+ bash: one class instead of N, cross-platform Dart, and a duplicate-key guard that turns silent overwrites into a failed build. - vs. one giant file: each team owns a small file; merge conflicts go to near zero.
If you maintain dozens of feature ARBs and hundreds of keys, editing raw JSON by hand gets error-prone fast. Our free ARB editor validates placeholders and plurals per file, and the full l10n.yaml configuration guide covers every option above in depth. To understand exactly why a single delegate is the right call, read our localization delegates deep dive.
Try FlutterLocalisation free
Managing per-feature ARB files across many languages is exactly what FlutterLocalisation automates—edit, translate, and sync clean ARB files straight to your repo so CI runs the merge above on every build. Start free and stop losing afternoons to localization merge conflicts.