← Back to Blog

Split app_en.arb into per-feature ARB files with gen-l10n

flutterarbgen-l10nlocalizationi18nmonorepo

Split app_en.arb into per-feature ARB files with gen-l10n

flutter gen-l10n reads exactly one template ARB per project (the template-arb-file in l10n.yaml, default app_en.arb) and one translated ARB per locale. There is no include directive and no glob. So when your app_en.arb passes a few hundred keys, every feature branch touches the same file, reviews are slow, and merge conflicts land on a JSON blob nobody wants to resolve.

You cannot change what gen-l10n accepts, but you can change what feeds it. There are three realistic ways to run Flutter with multiple ARB files, and they differ mostly in whether you end up with one AppLocalizations class or one class per feature.

Option Keeps one AppLocalizations? Extra dependency Best for
Dart merge script before gen-l10n Yes None Most apps, monorepos
split_intl / json_to_arb Yes One dev package Teams that want jsonc comments or automatic key prefixes
l10n_decompose No, one class per feature One dev package Feature packages with their own delegates

Option 1: merge per-feature ARBs with a Dart script

The layout: fragments live under lib/l10n/src/<feature>/, and the merged files that gen-l10n consumes are written to lib/l10n/. Keeping fragments outside arb-dir means gen-l10n never sees them, so there is nothing to misparse.

lib/l10n/
  app_en.arb          <- generated by the merge, committed
  app_de.arb
  src/
    auth/auth_en.arb
    auth/auth_de.arb
    cart/cart_en.arb
    cart/cart_de.arb
    common/common_en.arb
    common/common_de.arb

A fragment is a normal ARB. It does not need @@locale; the merge adds it:

{
  "authSignInTitle": "Sign in",
  "@authSignInTitle": {"description": "Title of the sign-in screen"},
  "authPasswordResetSent": "We sent a reset link to {email}",
  "@authPasswordResetSent": {
    "placeholders": {"email": {"type": "String"}}
  }
}

The merge script, saved as tool/merge_arb.dart:

import 'dart:convert';
import 'dart:io';

const srcDir = 'lib/l10n/src';
const outDir = 'lib/l10n';

void main(List<String> args) {
  final check = args.contains('--check');
  final byLocale = <String, Map<String, dynamic>>{};
  final owner = <String, String>{}; // key -> file that defined it

  final files = Directory(srcDir)
      .listSync(recursive: true)
      .whereType<File>()
      .where((f) => f.path.endsWith('.arb'))
      .toList()
    ..sort((a, b) => a.path.compareTo(b.path));

  for (final file in files) {
    final name = file.uri.pathSegments.last; // auth_en.arb
    final locale = name.substring(name.lastIndexOf('_') + 1, name.length - 4);
    final json = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
    json.remove('@@locale');

    final merged = byLocale.putIfAbsent(locale, () => {});
    for (final entry in json.entries) {
      final k = entry.key;
      if (merged.containsKey(k)) {
        stderr.writeln('Duplicate key "$k" in ${file.path} '
            '(already defined in ${owner['$locale/$k']})');
        exit(1);
      }
      merged[k] = entry.value;
      owner['$locale/$k'] = file.path;
    }
  }

  final encoder = const JsonEncoder.withIndent('  ');
  var dirty = false;
  for (final locale in byLocale.keys) {
    final sortedKeys = byLocale[locale]!.keys.toList()
      ..sort((a, b) => a.replaceFirst('@', '').compareTo(b.replaceFirst('@', '')));
    final out = <String, dynamic>{'@@locale': locale};
    for (final k in sortedKeys) {
      out[k] = byLocale[locale]![k];
    }
    final text = '${encoder.convert(out)}\n';
    final target = File('$outDir/app_$locale.arb');
    if (!target.existsSync() || target.readAsStringSync() != text) {
      dirty = true;
      if (!check) target.writeAsStringSync(text);
    }
  }

  if (check && dirty) {
    stderr.writeln('Merged ARB files are stale. Run: dart run tool/merge_arb.dart');
    exit(1);
  }
}

The sort keeps @key metadata right after key, so diffs on the merged file stay readable. Run it before every generation:

dart run tool/merge_arb.dart && flutter gen-l10n

Your l10n.yaml does not change at all:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-dir: lib/l10n/generated

On Flutter 3.32 and newer the synthetic package:flutter_gen is gone by default, so set output-dir explicitly and import the generated file by path.

Commit the merged app_<locale>.arb files. Editors, translation tools and the ARB-aware linters all keep working, and the --check mode below makes sure nobody edits the merged file by hand.

Option 2: split_intl or json_to_arb

If you would rather not maintain the script, two packages do the same job.

split_intl reads lib/l10n/<locale>/*.arb|json|jsonc and concatenates them into lib/l10n/app_<locale>.arb. Its selling point is .jsonc, so translators and reviewers can leave comments that are stripped on merge:

flutter pub add dev:split_intl
dart run split_intl:generate
flutter gen-l10n

json_to_arb goes further: fragments are plain JSON under lib/l10n/en/, lib/l10n/de/ and so on, and keys get an automatic prefix from the file and folder they live in. A title key in en/auth/sign_in.json comes out as a prefixed key in the merged ARB, which removes the need for the manual naming convention below. Configure it in json_to_arb.yaml:

source: lib/l10n
output: lib/l10n/app_arb
locales: [en, de]

Then point arb-dir in l10n.yaml at lib/l10n/app_arb and run dart run json_to_arb (or --watch during development) before gen-l10n. Note that the one-folder-per-locale layout groups by language, not by feature, so a feature's strings are spread across folders. That is fine for CODEOWNERS on a path, less fine for a feature package that wants everything in one place.

Option 3: l10n_decompose for one class per feature

l10n_decompose takes the opposite stance. Instead of merging, it finds every ARB group matching a pattern and runs flutter gen-l10n once per group, producing AuthLocalizations, CartLocalizations and so on, each with its own delegate.

dart pub add dev:l10n_decompose
dart run l10n_decompose

With the defaults (input: "**_en.arb", output: "gen/*_localization.dart", output-class: "*Localizations"), features/auth/l10n/auth_en.arb produces features/auth/l10n/gen/auth_localization.dart with an AuthLocalizations class. You register every delegate in MaterialApp.localizationsDelegates, or enable the composite option to get one file that aggregates them.

Two things to know. It renames your root l10n.yaml while it runs so the global config does not leak into per-feature runs. And you lose the single AppLocalizations.of(context) entry point: each screen imports its own feature class. That is exactly what a package-per-feature monorepo wants, and exactly what a single-package app usually does not.

A key-prefix convention that survives the merge

With option 1 or split_intl, nothing stops cart_en.arb and auth_en.arb from both defining title. The merge script fails on duplicates, but a convention prevents them in the first place:

  • Prefix every key with the feature name in camelCase: authSignInTitle, cartEmptyState, commonRetry.
  • Shared strings go in common/ and only in common/. If two features need the same string, move it there instead of copying.
  • The prefix must match the folder. A reviewer can see at a glance that cartX in auth_en.arb is in the wrong file.

You can enforce the prefix in the script with one check inside the loop:

final feature = file.parent.uri.pathSegments
    .lastWhere((s) => s.isNotEmpty);
if (!k.startsWith('@') && !k.startsWith(feature)) {
  stderr.writeln('Key "$k" in ${file.path} must start with "$feature"');
  exit(1);
}

The CI guard: a missing locale fails the build

The failure mode that a split introduces is silent. Someone adds payments/payments_en.arb, forgets payments_de.arb, and German users see untranslated keys because gen-l10n fills gaps from the template without complaint. Guard it in CI:

#!/usr/bin/env bash
set -euo pipefail
LOCALES=(en de fr)
status=0
for feature in lib/l10n/src/*/; do
  name=$(basename "$feature")
  for loc in "${LOCALES[@]}"; do
    if [[ ! -f "$feature/${name}_${loc}.arb" ]]; then
      echo "Missing ${name}_${loc}.arb"
      status=1
    fi
  done
done
exit $status

Then the pipeline is three lines:

- run: bash tool/check_arb_locales.sh
- run: dart run tool/merge_arb.dart --check
- run: flutter gen-l10n

The --check run fails when a merged file in lib/l10n/ is out of date, which also catches the case where someone edited app_de.arb directly instead of the fragment.

Why plural and ICU validation still runs on the merged file

Splitting changes where strings are authored. It does not change what gen-l10n validates, and it does not catch the errors that actually ship broken UI: a Polish fragment that drops the many case, a Russian few that was never written, or an Arabic plural missing zero. gen-l10n accepts those files; the app just shows the other form in the wrong grammatical number.

That check has to run against the complete per-locale set, because the question "does this locale cover every plural category it needs" is about the locale, not about the feature file. The simplest place to run it is on the merged app_<locale>.arb, which is what the FlutterLocalisation ARB editor works on: load the merged files, and its ICU plural-syntax validation flags locales that are missing a plural category the language requires. Fix the string there, copy it back into the fragment, and the merge script's --check keeps the two in sync.

Picking one

Use the merge script if you have one app package and want zero new dependencies. Use json_to_arb if you want the prefixing done for you and like JSON folders per locale. Use l10n_decompose if your features are already separate Dart packages and should own their localizations end to end. In all three cases keep the per-locale output committed, run the CI guard, and validate ICU plurals on the merged result rather than on fragments.

If the merged files are where translators and reviewers spend their time, try FlutterLocalisation free and edit them in the ARB editor instead of raw JSON.