All articles
10 min read

How to Translate Flutter ARB Files

Working with Flutter’s ARB format and gen_l10n: key naming rules, placeholders and plurals, metadata for translators, and automating the translation round trip.

FlutterARBMobile

Flutter's built-in localization pipeline is genuinely good once you understand it, and genuinely confusing until you do. The format is ARB, the code generator is gen_l10n, and most of the pain people hit – cryptic build failures, placeholders that refuse to compile, keys that silently break – comes from a handful of rules that are never stated in one place.

This walks through the whole thing: what ARB actually is, how to configure and generate AppLocalizations, the key-naming and metadata rules that trip people up, ICU plurals and selects with working examples, and how to get files out to translators and back in without hand-editing JSON.

What ARB actually is

ARB stands for Application Resource Bundle. It is a JSON file with a convention layered on top: string resources at the top level, and metadata for each resource in a sibling key prefixed with @.

{
  "@@locale": "en",
  "appTitle": "Field Notes",
  "@appTitle": {
    "description": "Application name shown in the app bar and task switcher"
  },
  "welcomeBack": "Welcome back, {name}",
  "@welcomeBack": {
    "description": "Greeting on the home screen after sign-in",
    "placeholders": {
      "name": {
        "type": "String",
        "example": "Ada"
      }
    }
  }
}

Two things follow from that structure. First, ARB is single-language – one file per locale, unlike JSON or YAML resource files that can nest all languages in one document. Second, the metadata is part of the file, so translator-facing context travels with the strings instead of living in a spreadsheet someone will lose.

@@locale is a file-level directive naming the locale of the file. Flutter can infer it from the filename, but declare it anyway: it is the only thing that survives a file rename, and tools use it to route the file to the right target language.

Project layout and l10n.yaml

The default layout puts ARB files in lib/l10n, named app_<locale>.arb:

lib/
  l10n/
    app_en.arb        <- template (source of truth)
    app_de.arb
    app_fr.arb
    app_pt_BR.arb

Locale suffixes follow the language_SCRIPT_REGION pattern, so app_zh_Hant_TW.arb is valid. Enable generation in pubspec.yaml and add the delegates package:

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: any

flutter:
  generate: true

Then create l10n.yaml at the project root:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n/generated
synthetic-package: false
nullable-getter: false
untranslated-messages-file: untranslated_messages.txt

Two of those settings are worth calling out. synthetic-package: false writes the generated Dart into your source tree instead of a hidden package – the generated code becomes greppable, diffable, and importable by a normal relative path, which is worth the small amount of noise in .gitignore. That key has been deprecated on recent Flutter SDKs, which write to output-dir in your tree by default, so check what your SDK version accepts before adding the line. And nullable-getter: false makes AppLocalizations.of(context) return a non-nullable instance, so you stop writing ! at every call site.

The template file is authoritative. gen_l10n reads keys, placeholders and metadata from it; every other locale file is treated as a translation of it. Metadata blocks in non-template files are ignored, so there is no point duplicating descriptions into app_de.arb.

Key naming: the rule that bites everyone

ARB keys become Dart getters, so they must be valid Dart identifiers. The generator validates each resource id against roughly [a-zA-Z][a-zA-Z_0-9]* and refuses anything else.

Concretely:

KeyValidWhy
welcomeBackyescamelCase identifier
settings_titleyesunderscores are allowed inside
home.titlenodots are not legal in Dart identifiers
2faPromptnocannot start with a digit
classnoreserved word
checkout buttonnospaces

The dot case is the one that causes real damage, because dotted keys are the norm in web i18n – i18next, Vue I18n and Rails all encourage home.header.title. Teams porting a web catalogue to Flutter paste it in and the build dies. Pick a flattening convention up front and apply it mechanically: home.header.title becomes homeHeaderTitle, or home_header_title if you prefer visible grouping. Do it once, at import time, and never mix the two styles.

Keep keys semantic rather than literal. emptyStateProjects survives a copy rewrite; noProjectsYetExclamation does not.

Metadata is what makes translations correct

The description field is optional and skipping it is the single biggest quality mistake in Flutter localization. A translator working from a spreadsheet sees the word "Open" with no context. In German that is Öffnen (verb, a button) or Offen (adjective, a status label) – different words, and there is no way to guess from the string alone.

{
  "open": "Open",
  "@open": {
    "description": "Button label. Verb - opens the selected document."
  },
  "statusOpen": "Open",
  "@statusOpen": {
    "description": "Status badge on a support ticket. Adjective - the ticket is not yet resolved."
  }
}

Write descriptions for anything short, anything ambiguous, anything with a placeholder, and anything where length matters ("must fit a 12-character tab label"). The example field on a placeholder is equally cheap and equally useful – it tells the translator whether count is realistically 2 or 20,000, which changes phrasing in several languages.

Placeholders, types and formats

Every placeholder used in a message must be declared in the metadata block. The type drives the generated Dart signature.

{
  "lastSyncedAt": "Last synced {when}",
  "@lastSyncedAt": {
    "placeholders": {
      "when": {
        "type": "DateTime",
        "format": "yMMMd",
        "isCustomDateFormat": "false"
      }
    }
  },
  "storageUsed": "{bytes} of your quota used",
  "@storageUsed": {
    "placeholders": {
      "bytes": {
        "type": "int",
        "format": "compact"
      }
    }
  },
  "orderTotal": "Total: {amount}",
  "@orderTotal": {
    "placeholders": {
      "amount": {
        "type": "double",
        "format": "currency",
        "optionalParameters": {
          "symbol": "€",
          "decimalDigits": 2
        }
      }
    }
  }
}

Supported types are String, int, double, num and DateTime. Number formats map to intl's NumberFormat constructors (compact, decimalPattern, currency, percentPattern, and friends); date formats map to DateFormat skeletons (yMd, yMMMd, jm, and so on). Locale-aware formatting is the entire point – do not pre-format numbers into strings in Dart and pass them as String, or German users get 1,234.50 where they expect 1.234,50.

If you need a literal brace in a message, set use-escaping: true in l10n.yaml and wrap it in single quotes: '{' renders as a brace.

Plurals and selects

ARB uses ICU MessageFormat. The plural form takes a numeric placeholder and one message per CLDR plural category:

{
  "unreadCount": "{count, plural, =0{No unread messages} =1{One unread message} other{{count} unread messages}}",
  "@unreadCount": {
    "description": "Inbox badge tooltip",
    "placeholders": {
      "count": {
        "type": "int",
        "example": "12"
      }
    }
  }
}

The categories are zero, one, two, few, many, other, plus exact matches like =0. English only needs one and other; Russian needs one, few, many, other; Arabic uses all six. Your template file only declares the forms English needs – translators add the rest in their own file. That is legal and expected, and it is why you cannot machine-check that a translation has "the same shape" as the source.

Select works the same way for enumerated values:

{
  "inviteMessage": "{role, select, owner{{name} owns this project} admin{{name} can manage this project} other{{name} can view this project}}",
  "@inviteMessage": {
    "placeholders": {
      "role": { "type": "String" },
      "name": { "type": "String" }
    }
  }
}

Always provide an other branch. Both plural and select require it, and the generator will reject the message without one.

Generating and using AppLocalizations

flutter gen-l10n regenerates on demand; flutter run and flutter build do it automatically when generate: true is set. Wire the delegates into your app:

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/generated/app_localizations.dart';

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const HomePage(),
    );
  }
}

Then call it in widgets:

@override
Widget build(BuildContext context) {
  final l10n = AppLocalizations.of(context);
  return Column(
    children: [
      Text(l10n.welcomeBack('Ada')),
      Text(l10n.unreadCount(12)),
      Text(l10n.lastSyncedAt(DateTime.now())),
    ],
  );
}

Note the generated API is type-safe: unreadCount takes an int, and passing a String is a compile error rather than a runtime surprise. That is the main advantage of ARB over a hand-rolled map lookup.

Errors you will actually hit

"Placeholder is not defined in the message" – you used {name} in the string but omitted it from the placeholders block. Every placeholder must be declared, even a plain String.

Plural or select fails to compile – the controlling placeholder is missing, or typed as String when a plural needs int/num, or there is no other branch.

untranslated_messages.txt appears – this is a report, not a failure. With untranslated-messages-file configured, gen_l10n writes a per-locale list of keys present in the template but missing from a translation file. Read it in CI:

flutter gen-l10n
test ! -s untranslated_messages.txt || { cat untranslated_messages.txt; exit 1; }

Missing keys fall back to the template locale at runtime, so an untracked gap ships as English text in a German build rather than crashing. Treat the file as a release gate.

Keys silently missing from generated code – usually an invalid identifier, or a stray @-prefixed key with no matching resource.

A locale never loads – the locale is not in supportedLocales (which is derived from the ARB files present), or the filename suffix does not match the BCP 47 form Flutter expects.

The round trip: getting files to translators and back

Handing app_de.arb to a translator as a raw file works exactly once. The problems start on the second pass: you added twelve keys, changed three, and now someone has to diff JSON by hand and preserve the @ blocks while doing it.

A workable loop looks like this:

  1. Export the template plus every existing translation into a system that understands ARB, so the @key descriptions and placeholder examples land in the translator's view instead of being stripped.
  2. Translate only the delta – new and changed keys – leaving strings that are already marked approved untouched.
  3. Import back one ARB file per locale into lib/l10n, then run flutter gen-l10n and let the build tell you if anything is malformed.
  4. Gate the release on an empty untranslated_messages.txt.

Translatize imports and exports ARB in both directions alongside eight other formats, which matters if the same product strings also live in a web app – the shared keys can leave as JSON or XLIFF for the web build and as ARB for Flutter, from one catalogue. AI auto-translation fills missing values for a first pass on the Professional and Agency plans, with a human reviewing before anything reaches approved – the review statuses are draft, review, approved and rejected, and gating your import on approved is the point of having them.

Automating it

Whatever tool you use, the goal is that no human hand-edits an ARB file after the template changes. A CI job on merge to main can push the updated source strings, and a scheduled job can pull completed translations back and open a PR.

The @translatize/cli commands are init, push, pull and status, and they read the project, branch and file pattern from a translatize.config.json written by npx @translatize/cli init. push sends new and changed local keys and never deletes remote ones; status is the gate:

- name: Push source strings
  run: npx @translatize/cli push --branch main

- name: Fail the build on an incomplete language
  run: npx @translatize/cli status --branch main --fail-on-missing

- name: Verify generation
  run: |
    flutter gen-l10n
    test ! -s untranslated_messages.txt

The CLI works with per-language files on disk in JSON, so in a Flutter repo the ARB itself comes out of the platform's export – from the app, or from the @translatize/core SDK, which returns the raw file body for any of the nine formats:

import { writeFile } from 'node:fs/promises';
import { TranslatizeClient } from '@translatize/core';

const client = new TranslatizeClient({
  apiUrl: 'https://api.translatize.com/v1',
  token: process.env.TRANSLATIZE_API_TOKEN,
});

for (const lang of ['de', 'fr', 'pt']) {
  const arb = await client.exportFile({ format: 'arb', lang });
  await writeFile(`lib/l10n/app_${lang}.arb`, arb);
}

Integration tokens are prefixed mcni_ and bound to a single project and branch, so a CI token cannot write to main by accident. Current flag names and endpoints are in the docs.

The verification step is the important one. Any pipeline that writes ARB files into your repo must be followed by a real gen_l10n run, because that is the only check that catches a placeholder a translator accidentally deleted or a plural branch that lost its other case.

If you are wiring this into a broader process, our piece on the AI localization workflow covers the review-gating side in more depth, and translating JSON files with AI is the closer analogue if your web app shares the same string catalogue.

Where Translatize fits

ARB is one of nine formats Translatize imports and exports symmetrically – the file you export is the file you can import back. Because ARB is single-language, each locale comes out as its own app_<locale>.arb, while the catalogue itself holds every language together, so adding a target language is another export rather than another file to hand-maintain.

The @key metadata maps onto per-label context: the description you already write travels into the translator's view, and charLimit covers the "must fit a 12-character tab label" constraint that ARB has nowhere to record. Placeholder and plural messages are stored as text, so ICU syntax survives the round trip – your flutter gen-l10n run is still the thing that validates it.

Branching is what fits the Flutter release cycle: a feature branch carries the keys that feature adds, a translator can own that branch with no path to the strings in main, and only an admin or owner merges – using overwrite, keep-newer or manual conflict resolution. CI tokens are bound to one project and one branch.

Wrapping up

ARB rewards a small amount of up-front discipline. Settle on a key convention that produces valid Dart identifiers, write real descriptions and placeholder examples, declare every placeholder and give every plural and select an other branch, and configure untranslated-messages-file so gaps are visible before a build ships. Once the round trip is automated and verified by an actual flutter gen-l10n run, adding a language stops being a project and becomes a file.

Keep reading