Crowdin
/Blog

Flutter Localization Guide: Crowdin OTA for Real-Time Translation

16 min read
Flutter Localization Guide

What will you do if your marketing team finds a typo in a promo banner or a missing translation string? Let us imagine how you are doing that in a hurry – patch the codebase, run the CI/CD pipeline, and wait for the App Store review team to approve a hotfix. If you are running a fast-moving app, waiting 24-48 hours for a copy change means losing users and missing time-sensitive promos.

Standard Flutter localization guides show you how to set up static .arb files, and that’s it. That doesn’t cut it for production.

This guide walks through implementing a scalable localization architecture in Flutter using Provider and integrating Over-The-Air (OTA) updates for real-time delivery. You will be able to push translation fixes instantly to your users without app store updates.

What we will do:

  • Set up a testable directory structure and configure automated local file generation.
  • Write .arb files with placeholders and plurals to support complex grammar.
  • Build a reactive LocaleProvider that saves preferences and falls back to the system language.
  • Integrate Crowdin CLI to push base strings and pull translations automatically.
  • Deploy the Crowdin SDK to push real-time copy fixes, bypassing App Store reviews.
  • Construct a type-safe UI dashboard where parameters and strings are verified before compilation.

Let’s go!

Example: food delivery app

Instead of generic Hello World examples, we will use the core mechanics of our food delivery dashboard. We need to handle:

Flutter app localization - food delivery app example

  1. Placeholders: Dynamic strings like “Hi {name}, your order from {restaurant} is on the way!”
  2. Plurals (ICU syntax): Handling countdowns like “Arriving in 1 minute” vs. “Arriving in 10 minutes”.
  3. OTA hotfixes: A marketing banner that product managers can modify remotely.

Step 1: Clean directory structure and dependencies

First, let’s look at the clean, testable folder structure we will use:

Terminal window
lib/
├── l10n/
├── extensions/
└── context_extensions.dart # BuildContext shortcuts
├── generated/ # Auto-generated localization classes
├── app_en.arb # English source template
└── l10n.yaml # Flutter l10n config file
├── providers/
└── locale_provider.dart # Reactive state for language switching
├── services/
└── storage_service.dart # Shared Preferences persistent storage
└── main.dart # App initialization & UI root

Add the required packages to your pubspec.yaml:

pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations: # Built-in localization toolkit
sdk: flutter
intl: ^0.20.2 # Required for ICU plurals/formats
provider: ^6.0.5 # State management
shared_preferences: ^2.0.15 # Persistence layer
crowdin_sdk: ^1.1.1 # Crowdin OTA integration
flutter:
generate: true # Mandatory - tells Flutter to auto-generate localization classes

Configure the localization build tool by creating l10n.yaml at the project root:

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

Step 2: Writing the production-ready ARB file

Application Resource Bundle (.arb) files use standard JSON format. We add context descriptions and placeholder types so translators in the cloud don’t break the code.

lib/l10n/app_en.arb
{
"@@locale": "en",
"dashboardTitle": "BiteDash Delivery",
"orderStatus": "Hi {name}, your order from {restaurant} is on the way!",
"@orderStatus": {
"description": "Live checkout tracking status header",
"placeholders": {
"name": { "type": "String", "example": "Sarah" },
"restaurant": { "type": "String", "example": "Pizza Hut" }
}
},
"deliveryETA": "{count, plural, =0{Arriving now!} =1{Arriving in 1 minute} other{Arriving in {count} minutes}}",
"@deliveryETA": {
"description": "Courier arrival countdown",
"placeholders": {
"count": { "type": "int", "example": "12" }
}
},
"promoBanner": "Match Day Special: Free delivery on all pizza orders tonight!"
}

Step 3: Understanding ICU Syntax (placeholders and plurals)

Because Flutter uses the standard ICU syntax for localization, the build tool converts your .arb keys into type-safe Dart methods. Here is how they work under the hood:

1. Dynamic placeholders

When you define variables inside curly braces like {name}, Flutter doesn’t just treat it as text. It treats it as a method parameter.

  • In the ARB file: You must explicitly define the type (e.g., String, int, num) and an example so the cloud translation platform knows how to render previews for translators.
  • In Dart: The auto-generated AppLocalizations class will turn "orderStatus" into a positional method:
// Auto-generated signature:
String orderStatus(String name, String restaurant);

2. ICU plurals

Handling numbers across languages is tricky. English only uses one and other (e.g., “1 minute” vs “2 minutes”). However, other languages like Polish, French, or Arabic have much more complex plural forms (for example, French changes rules for 0, while Polish has different forms for numbers ending in 2, 3, or 4 like 1 minuta, 2 minuty, 5 minut).

The ICU syntax solves this by letting you define specific plural blocks (=0, =1, one, few, many, other):

"deliveryETA": "{count, plural, =0{Arriving now!} =1{Arriving in 1 minute} other{Arriving in {count} minutes}}"
  • =0 and =1 are strict explicit matches (if the number is exactly 0 or 1).
  • other is the mandatory fallback clause required by Flutter for any numbers that don’t match the specific rules.

When Flutter compiles this, it generates a method that accepts an integer (int):

// Auto-generated signature:
String deliveryETA(int count);

At runtime, Flutter will automatically check the active locale’s specific grammatical rules and easily pick the correct text variant.

Step 4: Setting up persistence and state

We need the selected language to persist across app restarts. We will abstract SharedPreferences into a lightweight storage service.

lib/services/storage_service.dart
import 'package:shared_preferences/shared_preferences.dart';
class StorageService {
final SharedPreferences _prefs;
StorageService._(this._prefs);
static Future<StorageService> getInstance() async {
final prefs = await SharedPreferences.getInstance();
return StorageService._(prefs);
}
Future<void> saveLanguageCode(String code) async {
await _prefs.setString('selected_language_code', code);
}
String? getLanguageCode() {
return _prefs.getString('selected_language_code');
}
}

Next, create the LocaleProvider to handle runtime language switching, automatically fall back to the device’s native system language on first launch, and notify UI listeners when changes occur.

lib/providers/locale_provider.dart
import 'package:flutter/material.dart';
import '../services/storage_service.dart';
class LocaleProvider extends ChangeNotifier {
Locale _locale;
Locale get locale => _locale;
// Constructor cleanly instantiates with the ready-to-go startup language
LocaleProvider({required Locale initialLocale}) : _locale = initialLocale;
void setLocale(Locale locale) async {
_locale = locale;
final storage = await StorageService.getInstance();
await storage.saveLanguageCode(locale.languageCode);
notifyListeners();
}
}
Future<void> _init() async {
final storage = await StorageService.getInstance();
final saved = storage.getLanguageCode();
if (saved != null) {
_locale = Locale(saved);
} else {
// First-time setup fallback: Use device's native system language
final systemLocale = WidgetsBinding.instance.platformDispatcher.locales.first;
_locale = Locale(systemLocale.languageCode);
}
// Updates are nested within the async flow to ensure proper order of execution
_isLoading = false;
notifyListeners();
}

Step 5: Connect Crowdin CLI

Before jumping into live cloud updates, you need to establish the baseline translation pipeline. Instead of manually emailing .arb files to translators, we automate the file sync using Crowdin.

1. Project setup

  • Create a localization project in your Crowdin console and select your source (English) and target languages (for example, Spanish).
  • Install the Crowdin CLI on your local machine or build server to manage file transfers via the terminal.

2. Configure the Cloud Sync (crowdin.yml)

Create a crowdin.yml file at the root of your Flutter project. This configuration maps your local environment directly to the Crowdin file repository:

crowdin.yml
project_id: "YOUR_PROJECT_ID"
api_token_env: "CROWDIN_PERSONAL_TOKEN"
base_path: "."
files:
- source: /lib/l10n/app_en.arb
translation: /lib/l10n/app_%two_letters_code%.arb

3. Syncing files via terminal

Now, you can push your source strings and pull completed translations with two simple commands:

Terminal window
# 1. Upload your baseline app_en.arb template to Crowdin
crowdin push
# 2. Download completed translations directly into your /lib/l10n/ folder
crowdin pull

Step 6: Upgrading to OTA live updates

While the file-sync workflow is great for scheduled app releases, hot-fixing a translation typo or pushing an urgent marketing promo line shouldn’t require a full App Store deployment cycle. This is where we upgrade our setup to Crowdin Over-The-Air (OTA).

Instead of relying purely on the static assets compiled inside the app binary, the crowdin_sdk intercepts native string lookup requests and applies live overrides downloaded from Crowdin’s global Content Delivery Network (CDN).

OTA architecture overview

Here is how data flows from Crowdin console down to your Flutter application at runtime:

How data flows from Crowdin console down to the Flutter app

1. Set up an OTA distribution

In your Crowdin Project Settings, navigate to Distributions, create a new mobile distribution channel, and copy your unique Distribution Hash.

2. Run the Crowdin local code generation

The Crowdin SDK requires you to build a bridge between local dynamic asset loading and native lookups. To generate the underlying CrowdinLocalization delegation bindings, run:

Terminal window
dart run crowdin_sdk:gen

OTA cache and lifecycle delay

When using Over-The-Air translation delivery, junior developers often make the mistake of modifying a string in the Crowdin dashboard, clicking “Save”, and expecting an open app simulator to immediately change text on the screen.

In production, it doesn’t work this way due to how network efficiency and user experience are balanced:

  1. Background caching strategy: The crowdin_sdk executes network requests asynchronously on a background thread. When Crowdin.init() is invoked, it checks the local device cache first to ensure a zero-latency layout paint. It then checks the Crowdin CDN for updates in the background.
  2. Lifecycle delay: If a new translation bundle is found on the CDN, the SDK downloads it, writes it to local storage, and caches it. However, to prevent UI text from jarringly transforming or flashing in front of the user’s eyes mid-session, these downloaded updates typically take effect on the subsequent application launch or when a clean hard-restart occurs.
  3. Bandwidth optimization: The updatesInterval: const Duration(minutes: 15) parameter governs how frequently the SDK will poll the CDN. Setting this too low drains user battery and cellular data; 15 to 30 minutes is the production sweet spot for fast-moving apps.

Always prepare your QA and product management teams for this behavior: dynamic OTA copy fixes are “instant” compared to a 48-hour App Store review cycle, but they still respect the application’s lifecycle boundaries.

Step 7: Configuring the app entry point (lib/main.dart)

The lib/main.dart file houses two distinct responsibilities:

  1. Native bindings and initialization (void main()): Where we configure the asynchronous storage and cloud components before anything renders.
  2. Root Widget Layer (class MyApp): Where the framework connects state notification streams down into the UI engine.

Create or update your lib/main.dart file with this unified implementation:

lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:crowdin_sdk/crowdin_sdk.dart';
import 'package:shared_preferences/shared_preferences.dart'; // Direct dependency for startup
import 'l10n/generated/app_localizations.dart';
import 'l10n/generated/crowdin_localizations.dart';
import 'providers/locale_provider.dart';
import 'bite_dash_dashboard.dart';
void main() async {
// Required to securely fetch SharedPreferences & connect Crowdin channels
// before the root MaterialApp UI tree mounts.
WidgetsFlutterBinding.ensureInitialized();
// Pro-Tip: Read the locale code from storage directly during the bootstrap phase.
// This completely eliminates cold-start UI loading flickers.
final prefs = await SharedPreferences.getInstance();
final savedLanguageCode = prefs.getString('selected_language_code');
Locale initialLocale;
if (savedLanguageCode != null) {
initialLocale = Locale(savedLanguageCode);
} else {
// Graceful fallback to native device settings if no choice is stored yet
final systemLocale = WidgetsBinding.instance.platformDispatcher.locales.first;
initialLocale = Locale(systemLocale.languageCode);
}
// Connect engine runtimes straight to Crowdin's Global CDN
await Crowdin.init(
distributionHash: 'YOUR_CROWDIN_DISTRIBUTION_HASH_HERE',
connectionType: InternetConnectionType.any,
updatesInterval: const Duration(minutes: 15),
);
// Pre-load translation strings for the initial locale to ensure
// they are ready before the UI hierarchy mounts.
await Crowdin.loadTranslations(initialLocale);
runApp(
ChangeNotifierProvider(
create: () => LocaleProvider(initialLocale: initialLocale),
child: const MyApp(),
),
);
runApp(
ChangeNotifierProvider(
// Pass the fully materialized initial locale straight into the state constructor
create: () => LocaleProvider(initialLocale: initialLocale),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// Grab the active locale state emitted by our provider
final provider = Provider.of<LocaleProvider>(context);
return MaterialApp(
locale: provider.locale,
// Intercepts framework text requests to apply real-time cloud overrides instantly
localizationsDelegates: CrowdinLocalization.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const BiteDashDashboard(),
);
}
}

Step 8: Platform configuration (iOS mandatory step)

While Android dynamically checks asset targets and reads localization codes at runtime automatically, iOS takes a strict, security-focused approach. iOS will reject or completely ignore dynamically requested languages from the Crowdin CDN unless they are explicitly pre-registered in the application bundle’s metadata payload.

To configure this, open ios/Runner/Info.plist and append your application’s supported localization array within the main <dict> block:

<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>es</string>
</array>

Rule: New languages require a Store update on iOS

It is critical for you and your product teams to understand that Info.plist is a static configuration that cannot be changed after compile time.

This is a standard operating system behavior for iOS, not a specific limitation or requirement of the Crowdin SDK. Apple strictly enforces metadata checks at the system level for all iOS applications.

  • Problem: If your marketing or localization team decides to suddenly target a new country (for example, adding French (fr) or German (de) inside the Crowdin console)the Crowdin OTA SDK cannot bypass this iOS metadata check. If a user changes their device language to German, iOS checks Info.plist first. Because <string>de</string> is missing from the compiled binary payload, iOS will refuse to pass that language code context down to Flutter, causing the SDK to drop back to your baseline English fallback string array.

  • Solution: Whenever business stakeholders request a brand-new target locale, the engineering team must manually append that language string identifier to the local Info.plist file, run a fresh production build compilation, and submit a complete application update through App Store Connect.

Always maintain a master list of planned expansion territories. Pre-registering languages in your Info.plist ahead of time (even before translations are written in Crowdin) is an elite architectural habit that ensures your over-the-air channel remains fully unhindered.

iOS localization tutorial with SwiftUI demo project

Step 9: Clean up your build context with a Dart extension

Writing AppLocalizations.of(context)!.stringName over and over cluttered your UI code, creates massive lines, and adds unnecessary boilerplate. To keep your code clean, type-safe, and highly readable, we can leverage a Dart Extension.

Create a new file named context_extensions.dart inside a helpers or extensions directory (e.g., lib/l10n/extensions/context_extensions.dart) and add the following code:

import 'package:flutter/material.dart';
import '../generated/app_localizations.dart';
extension LocalizedContext on BuildContext {
/// A clean shortcut to access localized strings via the current widget context.
/// Usage: context.l10n.yourStringKey
AppLocalizations get l10n => AppLocalizations.of(this)!;
}

Step 10: Implement type-safe localized UI components

Now, we consume the strings in our dashboard. Because the build engine converts .arb assets into strongly-typed Dart code, we get compile-time checks for all placeholder parameters and plurals.

Context extension cleans up the code layout to follow architectural standards.

lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'l10n/extensions/context_extensions.dart'; // Import the new extension shortcut
import 'providers/locale_provider.dart';
class BiteDashDashboard extends StatelessWidget {
const BiteDashDashboard({super.key});
@override
Widget build(BuildContext context) {
// Tip: Set 'listen: false' here. We only need the provider instance
// to call the `setLocale` method, not to re-trigger this entire massive layout tree.
// The framework's core MaterialApp handles tracking and rebuilding translation strings down the line.
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
// Mock live environment values
String customerName = "Alex";
String restaurantName = "Taco Bell";
int deliveryMinutesRemaining = 12;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.dashboardTitle),
actions: [
// We use a Selector widget here to rebuild ONLY the drop-down menu asset itself
// when a switch occurs, keeping our layout lightning fast.
Selector<LocaleProvider, Locale>(
selector: (_, provider) => provider.locale,
builder: (context, activeLocale, _) {
return DropdownButton<Locale>(
value: activeLocale,
items: const [
DropdownMenuItem(value: Locale('en'), child: Text('🇺🇸 English')),
DropdownMenuItem(value: Locale('es'), child: Text('🇪🇸 Español')),
],
onChanged: (newLocale) {
if (newLocale != null) {
localeProvider.setLocale(newLocale);
}
},
);
},
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Passing parameters safely into localized string templates
Text(
context.l10n.orderStatus(customerName, restaurantName),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// This component pulls live text straight from Crowdin OTA targets
Container(
padding: const EdgeInsets.all(12),
width: double.infinity,
color: Colors.orange.shade50,
child: Text(
context.l10n.promoBanner,
style: const TextStyle(color: Colors.orange.shade900, fontWeight: FontWeight.w700),
),
),
const SizedBox(height: 24),
// ICU Plural implementation handling plural branches natively
Card(
child: ListTile(
leading: const Icon(Icons.delivery_dining, size: 36),
title: const Text("Estimated Delivery"),
subtitle: Text(context.l10n.deliveryETA(deliveryMinutesRemaining)),
),
),
],
),
),
);
}
}

Conclusion

Separate your local application state via Provider and route string lookups through the Crowdin SDK for a clean, scalable localization setup. This allows product and localization teams to tweak marketing copies or add entirely new target languages without interrupting the engineering team’s focus on feature code.

FAQ

What happens if a user opens the app offline? Which translations will they see?

If there is no internet connection, crowdin_sdk immediately falls back to the static, offline-safe .arb file bundles that were compiled directly into your app binary. Because we run crowdin pull and generate local Dart classes during development, your users will always see a graceful fallback instead of broken keys or empty text fields.

Can I add a completely new language purely via Crowdin OTA without a store update?

On Android: Yes. On iOS: No. iOS enforces a strict system-level security check and will completely ignore dynamically requested assets unless that language identifier is pre-registered in your compiled ios/Runner/Info.plist metadata bundle. This is a native Apple restriction, not a limitation of Crowdin. If the language is missing from Info.plist, iOS refuses to pass the context to Flutter, causing the app to drop back to your English baseline.

Does using Crowdin OTA drain the user’s cellular data or battery?

Not if you configure your polling intervals wisely. By setting updatesInterval: const Duration(minutes: 15), the SDK throttles network queries so it only checks the CDN once every 15 minutes at most. A window of 15 to 30 minutes is the production sweet spot for high-traffic, fast-moving apps.

Yuliia Makarenko

Yuliia Makarenko

Yuliia Makarenko is a marketing specialist with over a decade of experience, and she’s all about creating content that readers will love. She’s a pro at using her skills in SEO, research, and data analysis to write useful content. When she’s not diving into content creation, you can find her reading a good thriller, practicing some yoga, or simply enjoying playtime with her little one.

Share this post: