devShakib

Internationalization That Doesn't Fall Apart at the First RTL Language

Flutter internationalization done right: RTL layout mirroring, ICU plurals and gender, intl date and currency formatting, ARB files, text expansion, and a translation pipeline.

A client in Dubai once asked me to "add Arabic support" to an app the week before a demo. They thought it was a translation task: hand the strings to a translator, wire up a language picker, done by Thursday. What actually happened is the whole layout flipped, half our icons pointed the wrong way, a date read as 2024/13/07, and a "Save changes" button that fit English perfectly got clipped in German three screens over. The Arabic was the easy 10%. The other 90% was everything we'd quietly baked into the UI while assuming English, left-to-right, and one grammatical form of everything.

That project taught me the thing I now say at the start of every Flutter localization conversation: swapping strings is trivial. Real internationalization is plurals, gender, dates, currency, RTL mirroring, and layouts that survive when a word triples in length. This is the post I wish I'd read before that demo — a practical guide to Flutter i18n that doesn't collapse the first time you add a right-to-left language.

Why string swapping is only 10% of real internationalization

If you think of i18n as a dictionary lookup, you've already lost. "Hello" becomes "مرحبا" and you feel productive. But language isn't a lookup table. It carries grammar, direction, and format, and all three leak into your UI whether you planned for them or not.

Here's what actually breaks, in rough order of how often it bit me across production apps:

String swapping handles none of these. The frustrating part is that the libraries to handle the rest already ship with Flutter — flutter_localizations, intl, and the gen_l10n toolchain. Most teams just never reach for them until a demo forces the issue.

My blunt opinion after doing this a few times: if you're storing plural logic or date formatting in your own Dart code, you have a bug you haven't hit yet. It's not a style preference. It's the difference between one locale working by accident and every locale working on purpose.

Setting up gen_l10n and ARB files in Flutter

Flutter's first-party localization path is gen_l10n. You define messages in ARB (Application Resource Bundle) files, the tool generates a typed AppLocalizations class, and you call methods instead of looking up raw strings. Turn it on in pubspec.yaml:

flutter:  generate: truedependencies:  flutter_localizations:    sdk: flutter  intl: any

Add an l10n.yaml at the project root so the generator knows where things live:

arb-dir: lib/l10ntemplate-arb-file: app_en.arboutput-localization-file: app_localizations.dart

Then wire the delegates and supported locales into your MaterialApp so the framework knows which locales exist and can resolve the right one at runtime:

MaterialApp(  localizationsDelegates: AppLocalizations.localizationsDelegates,  supportedLocales: AppLocalizations.supportedLocales,  // locale: const Locale('ar'), // force one during development  home: const HomeScreen(),)

An ARB file is just JSON with metadata. The real power is in ICU message syntax, which handles plurals and selects declaratively. This is where most people stop reading the docs, and it's the most important part.

ICU messages: plurals and select instead of if/else in Dart

Here's a template app_en.arb that covers a plural and a gender select:

{  "itemsInCart": "{count, plural, =0{Your cart is empty} =1{1 item in cart} other{{count} items in cart}}",  "@itemsInCart": {    "placeholders": {      "count": { "type": "int" }    }  },  "welcomeUser": "{gender, select, male{Welcome, sir} female{Welcome, madam} other{Welcome}}",  "@welcomeUser": {    "placeholders": {      "gender": { "type": "String" }    }  }}

At the call site it's just a typed method — no branching, no string interpolation of grammar:

Text(AppLocalizations.of(context).itemsInCart(cart.length))

The critical insight: you do not write if (count == 1) "item" else "items" in Dart. You describe the plural categories in the message, and each language's ARB file fills in the forms that language actually has. Your Dart code passes a number. The framework picks =0, =1, one, few, many, or other per the target locale's CLDR rules. Hardcode that logic once and you'll be rewriting it the day you add Polish.

Formatting dates, numbers, and currency with intl correctly

The intl package is the workhorse for locale-aware formatting, and it's easy to misuse. Two rules save you most of the pain.

First, always pass the locale explicitly. DateFormat.yMMMd() with no locale uses the ambient default, which in tests and background isolates is often not what you think:

final locale = Localizations.localeOf(context).toString();DateFormat.yMMMMd(locale).format(date);   // July 1, 2026  /  ١ يوليو ٢٠٢٦NumberFormat.decimalPattern(locale).format(1234567.89);NumberFormat.currency(locale: locale, name: 'AED').format(price);

Second, initialize locale data before you format for a non-default locale, or you'll get a runtime error the first time a French user opens the app:

import 'package:intl/date_symbol_data_local.dart';Future<void> main() async {  WidgetsFlutterBinding.ensureInitialized();  await initializeDateFormatting();  runApp(const MyApp());}

The subtle wins are the ones you don't think to test. NumberFormat renders Eastern Arabic digits (٠١٢٣) for ar locales automatically. Currency formatting puts the symbol on the correct side and uses the correct decimal separator: 1.234,56 in German, 1,234.56 in English. If you ever find yourself doing "\$${price.toStringAsFixed(2)}", stop. You've just hardcoded a US convention into every locale on earth — wrong symbol, wrong position, wrong separators.

One trap worth naming: never build a date by concatenating your own translated month names. Use DateFormat. The ordering of day, month, and year is itself locale data, and gluing pieces together by hand is how you ship 2024/13/07 to a locale that expects day-first.

If you need relative times ("3 hours ago"), reach for a locale-aware helper rather than rolling your own — the pluralization of "hour/hours/minute" is the same CLDR problem in disguise, and hand-rolled relative time is one of the most common places English assumptions sneak back in.

RTL in Flutter: Directionality, logical insets, and what mirrors

RTL is where teams discover how many pixel-perfect assumptions they made. The good news: Flutter mirrors most of this for free if you stop using directional (physical) properties.

The rule is logical, not physical. Use the properties that respect reading direction:

Flutter reads the ambient Directionality, which MaterialApp sets from the resolved locale. Swap to Arabic and a Row reorders itself, start becomes the right edge, and your drawer slides in from the correct side. You do nothing.

Now, what mirrors and what doesn't — this distinction matters, because over-mirroring is as broken as under-mirroring:

For custom icons that should flip but aren't picked up automatically, mirror them yourself (math here is import 'dart:math' as math;):

Transform(  alignment: Alignment.center,  transform: Directionality.of(context) == TextDirection.rtl      ? Matrix4.rotationY(math.pi)      : Matrix4.identity(),  child: const Icon(Icons.reply),)

You can test RTL without translating a single string by forcing the direction on a subtree:

Directionality(  textDirection: TextDirection.rtl,  child: MyScreen(),)

Do this early. Finding out at the demo that your Positioned(left: 12) badge sits on the wrong shoulder in Arabic is a bad time to learn the difference between physical and logical coordinates. Two more RTL edge cases that bite: mixed-direction text (an English brand name inside an Arabic sentence) may need a Unicode bidi isolate to render cleanly, and a TextField cursor and alignment should be left to follow Directionality rather than pinned to TextAlign.left.

Designing layouts for text expansion, not pixel-perfect English

English is compact. Designing your UI to fit English exactly guarantees it breaks somewhere else. On one app we had a bottom nav with four labels perfectly centered in English, and German pushed "Benachrichtigungen" into a two-line wrap that shoved everything up by 14 pixels and broke the baseline alignment across the whole bar.

Rules I now design by:

A cheap trick that catches most of this before real translations exist: run the app with a pseudo-locale that pads and accents every string, turning "Save" into something like "[Šàvé———]". If your layout survives that, it'll survive real translations. It surfaces clipping, truncation, and hardcoded strings (anything that stays plain English didn't go through your localization layer) in one pass.

Pluralization and gender rules you cannot hardcode

I'll say it again because it's the single most common i18n bug I see: plural rules are not a boolean.

Arabic uses all six CLDR categories: zero, one, two, few, many, and other, and which one applies depends on the number in non-obvious ways. Russian is another classic: 1 is one, 2 is few, 5 is many, and it cycles by the last digit, so 21 is one again while 11 is many. You cannot encode this with if/else and stay sane across even a handful of locales. ICU's plural already knows every language's rules from CLDR; your job is only to supply each category's text.

Here's the English source, where you only fill the categories English actually has:

{  "notifications": "{count, plural, =0{No notifications} one{{count} notification} other{{count} notifications}}"}

The English ARB defines one and other. That's it, because English only has two grammatical number forms. The Arabic ARB for the same key fills zero, one, two, few, many, and other; the Russian one fills one, few, many, and other. Same key, same call site, different category sets per file, and the framework picks the correct branch at runtime based on the value you pass. The moment you try to do this yourself, you're maintaining a hand-rolled clone of CLDR — and you'll get it wrong.

Same discipline applies to gender and any grammatical agreement: use select, give the translator every branch, and never assume other is a safe catch-all in a language where a verb conjugates by the subject's gender. When in doubt, hand the translator the ICU message, not a pre-broken English sentence, and let them restructure it. They know their grammar; your string concatenation does not.

A translation workflow that scales past a spreadsheet

The first version of every localization I've done was a spreadsheet emailed to a translator. It works until it doesn't: keys drift, someone edits a formula, you paste 400 rows back by hand, and nobody knows which strings are stale. That's fine for 50 strings and one language. It collapses at 300 strings and four locales.

What actually scales:

The mindset shift: translation is a pipeline, not a favor you ask someone the week before release. Set up the pipeline once and adding a language becomes a config change, not a project.

Catching missing and broken translations before users do

The worst localization bug is the silent one: a French user sees an English string because someone added a key and forgot to translate it. It won't crash. It'll just look sloppy, and you'll hear about it from a customer instead of your CI.

Catch it in the pipeline, not in the wild:

None of this is glamorous. All of it is cheaper than a customer telling you your Arabic checkout screen has an English "Continue" button on it.

Key takeaways