ReRune: OTA localization for Flutter

ReRune for Flutter

Update translations and add languages in installed Flutter apps without waiting for an app store release, while continuing to use generated AppLocalizations APIs.

ReRune overlays remotely published messages on Flutter's generated localizations, so existing widget calls stay unchanged.

  • Keep generated getters and methods with full type safety for plain values, placeholders, and cardinal plurals.
  • Deliver approved text changes and new languages at runtime.
  • Restore cached translations at startup for offline use on mobile, desktop, and web.
  • Fall back per message through published source language content and the bundled Flutter value.

Explore ReRune ยท Report an issue

Remote Language Delivery

ReRune's core OTA value is remote language delivery: add a new supported language in the ReRune dashboard and make it available to existing apps without shipping a new app store build just for that language.

After the SDK fetches or loads the dashboard language bundle from cache, the locale appears in ReRune.supportedLocales. Apps can let the device locale resolve to it automatically, or build a language picker from ReRune.supportedLocales so remote languages appear next to compiled app locales.

The app still ships the generated localization API and at least one compiled fallback locale. ReRune updates values for existing keys and expands the runtime locale list from dashboard data.

Requirements

  • Flutter >=3.22.0
  • Dart >=3.4.0

Install

dependencies:
  rerune: ^1.8.0

dev_dependencies:
  # Optional: needed only if you use the build_runner path below.
  build_runner: ^2.4.13

1) Generate Flutter localizations

flutter gen-l10n

2) Generate ReRune localization config

Fast path using just ReRune:

dart run rerune

Alternative (build_runner pipeline):

dart run build_runner build

Both commands generate identical *.rerune.g.dart artifacts. Dedicated parity tests protect this behavior in CI.

With default Flutter l10n naming, ReRune generates:

  • lib/.../app_localizations.rerune.g.dart
  • reRuneAppLocalizationsConfig

No manual anchor class or annotation file is needed.

3) Wire ReRune.setup(...) in main()

import 'package:flutter/widgets.dart';
import 'package:rerune/rerune.dart';

import 'l10n/gen/app_localizations.rerune.g.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await ReRune.setup(
    otaPublishId: 'your-ota-publish-id',
    localizations: reRuneAppLocalizationsConfig, // Generated by rerune/build_runner
    updatePolicy: const ReRuneUpdatePolicy(checkOnStart: true),
  );

  runApp(const MyApp());
}

4) Use ReRune delegates/locales in your app

MaterialApp(
  localizationsDelegates: ReRune.localizationsDelegates,
  supportedLocales: ReRune.supportedLocales,
)

Use ReRune.supportedLocales anywhere you need the current runtime locale list, including custom language pickers. The list starts with compiled app locales and expands with fetched or cached dashboard languages.

Runtime APIs

  • Setup and load cached bundles: await ReRune.setup(...)
  • Check manually: await ReRune.checkForUpdates()
  • Change the active translation variant: await ReRune.setVariant(...)
  • Restore main translation content: await ReRune.resetVariant()
  • Listen when fetched OTA message values are applied: ReRune.onFetchedTextsApplied.listen(...)
  • Rebuild subtree on any localization revision: ReRuneBuilder(builder: ...)
  • Listen to any localization revision: ReRune.localizationsRevisionListenable

Once set up, ReRune keeps its own runtime failures away from your app. When a download, the cache store, a saved state or a remote text fails, ReRune logs it at the configured log level, and the API answers with a safe value: the bundled text, an update result whose errors describe the failure, or no change. Values that often arrive at runtime are logged instead of thrown: an invalid variant slug selects the main content, and a refresh interval that is not positive counts as not set. A mistake in how the app uses ReRune still throws, so it shows up during development: calling a ReRune API before ReRune.setup(...), an invalid configuration such as an empty publish ID, or persisting a variant with a store that does not support settings. A setup that throws keeps an earlier successful one active. Failures in your own bundled AppLocalizations code are not caught either, because they would happen without ReRune too, and neither is an error of your own listener that your FlutterError.onError rethrows: it fails the ReRune call that notified the listener. When that call is a setup, the new setup stays active without its automatic checks, as in 1.7.0. Call ReRune.setup(...) from main or another place outside of build. A setup while widgets build, such as from initState, is not supported: its texts apply, but ReRuneBuilder and ReRune.localizationsRevisionListenable do not update for it, and debug builds report a Flutter error; release and profile builds cannot detect a setup during the first mount after runApp, so there such a setup updates as usual. Listeners are notified once a setup has read its saved variant and texts. A setup with the same settings as the active one changes nothing; call ReRune.checkForUpdates() to check again. After ReRune.setStaging(...) or ReRune.setVariant(...) changed the selection, the same setup restores it. A setup that a listener of a ReRune notification starts, also after an await or from a timer, sends no notification of its own while it starts, so a listener that calls setup on every notification cannot set up endlessly.

ReRune does not force Flutter's root element to rebuild. Without ReRuneBuilder or a revision listener managed by your app, existing widgets remain unchanged until the application rebuilds them for another reason. Generated localization getters resolve against the current OTA bundles in memory on every invocation, so newly built widgets still receive the latest loaded values.

Translation variants

Variants solve an audience problem. Use one when the same translation key and language needs different wording for a customer, white-label brand, plan, role, region, experiment, or partner. Use a locale when the reader's language changes and a plural when grammar changes with quantity.

Main content is the default and the per-key fallback. Named Variants can stay sparse because a key without an override continues to use Main. Create a stable Variant name in the ReRune project, publish the content, then select its exact lowercase slug during setup:

await ReRune.setup(
  otaPublishId: 'your-ota-publish-id',
  localizations: reRuneAppLocalizationsConfig,
  variant: ReRuneVariant.named('customer_acme'),
);

If the selection becomes known later, change it without another setup call:

await ReRune.setVariant(
  variant: ReRuneVariant.named('customer_acme'),
  persist: true,
);

The change affects later localization lookups immediately and advances ReRune.localizationsRevisionListenable. Existing widgets still need ReRuneBuilder or an application-owned listener to rebuild. Call setVariant from an event handler or after a frame, not while widgets build, such as from initState or build: like setState there, debug builds report it as a Flutter error.

Read the active selection with ReRune.variant.

persist: false changes only the current runtime and leaves stored state unchanged. persist: true restores the selection during later setup calls for the same otaPublishId. A stored selection takes precedence over setup's variant argument, including a stored main selection.

await ReRune.setVariant(); // Main for this runtime only.
await ReRune.setVariant(persist: true); // Select and persist Main.
await ReRune.resetVariant(); // The same persisted Main behavior.

Variant slugs are exact lowercase values published by ReRune. A missing variant for a key or locale uses that localized entry's main content. A slug that is not valid, such as an empty string or one with capital letters or spaces, cannot name a published variant, so ReRuneVariant.named returns a selection equal to ReRuneVariant.main instead of throwing, and setup or ReRune.setVariant logs the invalid slug. Plain values, placeholders, and structured plurals continue through the same rendering path after content selection.

See the Translation Variants guide for dashboard workflow, use cases, and the distinction between audience, locale, and plural selection.

Staging draft translations

Staging mode previews enabled draft translations while project staging is enabled in the ReRune project. Use it in development and QA builds; production builds stay on the default path:

await ReRune.setup(
  otaPublishId: 'your-ota-publish-id',
  localizations: reRuneAppLocalizationsConfig,
  staging: true,
);

With staging: true, the manifest and locale requests carry exactly one staging=true query parameter. Locale responses are complete snapshots of the current locale, so deleted or disabled keys disappear and never-published keys appear. Draft ETags revalidate snapshots, and every synchronization checks every locale because manifest versions do not track draft edits. While project staging is disabled, the same requests return the published snapshot, so turning staging off replaces draft content on the next check.

The mode can also switch after setup:

final result = await ReRune.setStaging(true); // Preview drafts.
if (ReRune.isStaging) {
  // Render staging controls or diagnostics.
}
await ReRune.setStaging(false); // Back to published content.

ReRune.setStaging(bool) reads the selected mode's persisted namespace into the live session, rebuilds lookups, and immediately checks for updates in the selected mode. The resolved result describes that check, and ReRune.isStaging reports the active selection. The selection lasts for the current runtime; later setup calls restore the staging value passed there. A check captured before a switch is discarded at its next commit point, so fetched snapshots never bleed across modes.

Production and staging keep separate persisted namespaces: the controller holds one ReRuneCacheStore per mode, the bundled IO store derives a sibling cache directory for staging and the bundled web store a suffixed key prefix, and custom stores override the stagingNamespace getter to provide their own. Switching modes never applies draft content or draft validators to production requests, and the default staging: false keeps production behavior unchanged.

Diagnostic logging

ReRune logging is disabled by default. Consumers can select a level during setup:

await ReRune.setup(
  otaPublishId: 'your-ota-publish-id',
  localizations: reRuneAppLocalizationsConfig,
  logLevel: ReRuneLogLevel.info,
);
Level Output
ReRuneLogLevel.off No diagnostics. This is the default.
ReRuneLogLevel.error General failures without causes, stack traces, headers, or response bodies.
ReRuneLogLevel.info Failures plus request URLs, operations, and response status codes.
ReRuneLogLevel.verbose All available diagnostics, including raw headers, response bodies, causes, and stack traces.

verbose gives explicit access to sensitive data. It can expose the OTA publish ID and translated content in application logs. The lower levels never emit request headers or response bodies.

Dashboard Locale Additions

This is the remote language delivery path for apps that want language expansion to be controlled from the dashboard. await ReRune.setup(...) loads the cached manifest and generic locale bundles before the first app frame, so a dashboard locale fetched during a previous run is available in ReRune.supportedLocales before MaterialApp resolves the device locale. If checkOnStart is enabled, the network refresh starts in the background after cached bundles are loaded; it does not block setup. It runs again whenever the app returns to the foreground, so text published while the app was in the background appears when the user comes back.

After a successful manifest/update fetch, ReRune.supportedLocales contains the app's compiled locales plus dashboard locales whose locale bundle has been fetched or loaded from cache. When Flutter's generated delegate cannot load a dashboard locale, the generated delegate loads your bundled translation of the manifest main_language as the fallback base, or your first compiled locale when the app does not bundle the main language.

Publish each language under its plain language code, such as de, and add regional codes such as de-DE or de-AT only for texts that differ by region. ReRune treats de-DE and de_DE as the same locale. As with Flutter's own translation files, Flutter first picks one supported locale for the device, and texts are then looked up from that exact region to the plain language, never in another region of the same language. If the dashboard publishes only de-DE and your app bundles German, a device set to de_AT shows your bundled German instead of the dashboard text.

When a newly fetched manifest no longer lists a previously published locale, ReRune removes that locale's OTA bundle from memory and persistent cache. A dashboard locale then disappears from ReRune.supportedLocales; a compiled locale remains supported by the app but falls back through the remaining OTA chain and bundled translations.

OTA Fallback for Missing Keys

The backend manifest declares the project's source locale through the required main_language field. The value must identify one of the manifest's locales. When a key is missing from the requested OTA locale, ReRune resolves it in this order when your app bundles that language:

  1. Requested OTA locale variants
  2. Your bundled Flutter translation of that language

When your app does not bundle the language, for example one added only in the dashboard:

  1. Requested OTA locale variants
  2. OTA main_language variants
  3. Your bundled Flutter translation of the main language

For example, with main_language: "en" and bundled en and es, an es_MX request resolves through OTA es_MX, OTA es, and then your bundled Spanish text. A pl request resolves through OTA pl, OTA en, and then your bundled English text. Lookup is per key.

Placeholder, plural, and select messages use the locale of the OTA bundle that supplied the value. An English OTA fallback therefore uses English plural rules even when the active app locale is an unbundled language such as Polish.

ReRune accepts a locale update only when every text fits your app. If one dashboard value reads a placeholder your generated AppLocalizations member does not pass, or passes with a different type, or uses a {{name}} that its record does not declare, the whole locale update is rejected. The app keeps the last accepted texts for that locale, or resolves keys through the order above when there are none. Dashboard translations show values exactly as the app passes them. For dates, currency, or other formatted values, format them in your app and pass them as String placeholders. Flutter DateTime placeholders cannot be edited from the dashboard.

Manifests without a valid main_language are rejected. ReRune does not infer a main language from locale ordering, compiled locales, or the active app locale.

Languages Flutter or intl do not cover

Some languages, such as Hausa (ha), have no plural rules, number symbols, or date symbols in package:intl, and no Material or Cupertino translations in flutter_localizations. ReRune still shows your dashboard text in such a language:

  • Plural messages select the CLDR plural form. ReRune carries the rules for languages that intl lacks, currently Hausa. Languages that intl covers keep intl's rules.
  • A # in plural text shows the count. When intl has no number symbols for the language, ReRune formats the number as English does, which matches languages written with Latin digits.
  • A remote text that still cannot be rendered falls back to the bundled text and logs one warning per key and locale when logging is enabled.
  • Flutter's own controls, such as dialog buttons, the date picker, and tooltips, need translations that Flutter does not ship for the language. ReRune.localizationsDelegates loads them in the manifest main_language when Flutter has it, otherwise in your first compiled locale, and in English when Flutter has neither. Your own texts stay in the requested language, and the text direction follows Flutter's default for it.

Your own DateFormat and NumberFormat calls throw for such a language because intl has no symbols for it. Format with a locale that intl covers. DateFormat.localeExists needs the date data that Flutter loads with ReRune.localizationsDelegates, so call it from widgets below MaterialApp:

final locale = Localizations.localeOf(context).toString();
final numberLocale = Intl.verifiedLocale(
  locale,
  NumberFormat.localeExists,
  onFailure: (_) => 'en',
);
final dateLocale = Intl.verifiedLocale(
  locale,
  DateFormat.localeExists,
  onFailure: (_) => 'en',
);

If you add your own delegate for one of Flutter's localization types, list it before ...ReRune.localizationsDelegates so that it wins over the fallback.

Language Pickers

Build language pickers from ReRune.supportedLocales rather than from the compiled AppLocalizations.supportedLocales list. That gives consumers one source of truth for:

  • locales bundled into the app at build time
  • dashboard locales that were fetched in this app session
  • dashboard locales loaded from cache during startup

If a picker must update immediately after a background fetch in the current app session, rebuild it from ReRuneBuilder or listen to ReRune.localizationsRevisionListenable. Without that rebuild, the newly fetched language is available on the next app launch after await ReRune.setup(...) loads it from cache.

Use ReRune.localeName(locale) for the display name from the backend, committed with a locale's cached translations. It follows the same normalized locale fallback order as translation lookup and returns null when no successfully applied cache has a matching entry. Changes to names alone advance the general localization revision after the locale metadata is persisted.

ReRune does not own the selected app locale. Keep that state in your app, for example with a small LocaleNotifier, Riverpod, Bloc, or your existing app settings model. Use ReRune.supportedLocales for picker options and pass your selected locale to MaterialApp.locale. Apps without a language picker can omit MaterialApp.locale entirely and keep Flutter's normal device locale resolution.

class LocaleNotifier extends ValueNotifier<Locale?> {
  LocaleNotifier(super.value);
}

final LocaleNotifier localeNotifier = LocaleNotifier(null);

ReRuneBuilder(
  builder: (_) => ValueListenableBuilder<Locale?>(
    valueListenable: localeNotifier,
    builder: (_, locale, __) {
      return MaterialApp(
        locale: locale, // Optional: only for picker overrides managed by the app.
        localizationsDelegates: ReRune.localizationsDelegates,
        supportedLocales: ReRune.supportedLocales,
      );
    },
  ),
)

Set the notifier from your picker. Setting it to null returns to Flutter's normal device locale resolution.

DropdownButton<Locale?>(
  value: localeNotifier.value,
  hint: const Text('System default'),
  items: [
    const DropdownMenuItem<Locale?>(
      value: null,
      child: Text('System default'),
    ),
    ...ReRune.supportedLocales.map((locale) {
      return DropdownMenuItem<Locale?>(
        value: locale,
        child: Text(locale.toLanguageTag()),
      );
    }),
  ],
  onChanged: (locale) {
    localeNotifier.value = locale;
  },
)

Any selected locale that is not null should come from ReRune.supportedLocales. A custom LocaleNotifier does not collide with ReRune because ReRune does not expose a setter for the selected locale. ReRune does not fetch unavailable languages from picker selection. Dashboard languages appear in the list only after their locale bundle is fetched or loaded from cache.

Startup Modes

Use awaited setup when you want deterministic locale support on subsequent launches without waiting on the network:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await ReRune.setup(
    otaPublishId: 'your-ota-publish-id',
    localizations: reRuneAppLocalizationsConfig,
  );

  runApp(const MyApp());
}

Use the builder when you also want the app to follow the device language immediately after a background fetch in the current app session:

ReRuneBuilder(
  builder: (_) => MaterialApp(
    localizationsDelegates: ReRune.localizationsDelegates,
    supportedLocales: ReRune.supportedLocales,
  ),
)

Limits:

  • Without ReRuneBuilder or another rebuild trigger, existing widgets do not refresh solely because OTA content changed. Newly built widgets still resolve current OTA values for their loaded locale.
  • A dashboard locale fetched during the current run requires MaterialApp to rebuild before Flutter can reconsider supportedLocales and locale selection. Otherwise it is picked up on the next launch after await ReRune.setup(...) loads it from cache.
  • ReRune does not store the selected app locale. Apps with language pickers should keep that state themselves and pass it to MaterialApp.locale; null still means Flutter resolves the system language normally.
  • New localization keys or changed placeholder signatures still require running flutter gen-l10n and dart run rerune, then shipping an app update.
  • Platform language metadata, app store language metadata, and operating system language listings are not changed by OTA localization.

Advanced And Custom Integrations

API catalogs containing offset:0

SDK version 1.2.0 includes catalog preparation for Flutter's localization generator. Keep downloaded ARBs in lib/l10n, then run:

dart run rerune prepare-arb
flutter gen-l10n
dart run build_runner build

Set arb-dir: lib/l10n/flutter in l10n.yaml; retain the original template filename and generated Dart output directory. The preparation command owns lib/l10n/flutter, updates its derived ARBs, and removes stale locale files. Do not put authored files there. Commit these generated inputs alongside generated Dart so Flutter's automatic generation can run after a fresh checkout. Keep translation synchronization pointed at the original catalogs.

For build_runner automation, enable this additional builder in build.yaml:

targets:
  $default:
    builders:
      rerune:re_rune_arb_compatibility:
        enabled: true

After each pull, run preparation before flutter gen-l10n, then regenerate the overlay. With build_runner alone, run a preparation pass using dart run build_runner build --build-filter='lib/l10n/flutter/*.arb' before flutter gen-l10n, followed by a normal build_runner pass. The SDK leaves API data and source ARBs unchanged. It removes only redundant zero offset declarations from derived messages, preserving metadata and literal text. Flutter still validates the remaining ARB grammar.

Nonzero offsets in bundled ARBs are rejected because Flutter cannot generate equivalent fallbacks from them. Delivered JSON supports nonnegative integer offsets: exact selectors such as =2 match the original count first, then locale categories use the count minus the offset. Inside plural text, # formats that adjusted count; '#' prints a literal hash. Explicit arguments keep their original values. This does not claim complete ICU MessageFormat support or change the API.

Custom Flutter l10n class and file names

If your app does not use app_localizations.dart / AppLocalizations, add a build.yaml in the app root:

targets:
  $default:
    builders:
      rerune|re_rune_localizations_overlay:
        generate_for:
          - lib/**/my_localizations.dart
        options:
          localizations_file_name: my_localizations.dart
          localizations_class_name: MyLocalizations

Then run:

flutter gen-l10n
dart run rerune

# or

dart run build_runner build

This generates my_localizations.rerune.g.dart and reRuneMyLocalizationsConfig.

Use it in startup:

import 'package:rerune/rerune.dart';

import 'l10n/gen/my_localizations.rerune.g.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  ReRune.setup(
    otaPublishId: 'your-ota-publish-id',
    localizations: reRuneMyLocalizationsConfig,
  );

  runApp(const MyApp());
}

Check when the app returns to the foreground

With checkOnStart: true, the default, ReRune also checks for updates each time the app is shown again after being hidden: the user returns from another app or the home screen, switches back to the browser tab, or restores a minimized window. A notification shade or system dialog that only covers the app does not start a check, and a check that is already running is shared, so each return costs at most one small manifest request. An unchanged manifest answers 304 with no body. The CDN may serve the previous manifest for up to five minutes after a publish, so a return right after publishing can still show the earlier text.

This uses Flutter's AppLifecycleListener, so call WidgetsFlutterBinding.ensureInitialized() before ReRune.setup(...). Without the binding, ReRune.setup(...) throws and keeps any earlier setup.

Disable automatic update checks

checkOnStart: false turns off both the check after setup and the check when the app returns to the foreground. Call ReRune.checkForUpdates() when your app wants new text.

ReRune.setup(
  otaPublishId: 'your-ota-publish-id',
  localizations: reRuneAppLocalizationsConfig, // Generated by rerune/build_runner
  updatePolicy: const ReRuneUpdatePolicy(checkOnStart: false),
);

Schedule periodic refresh in hours or days

ReRune.setup(
  otaPublishId: 'your-ota-publish-id',
  localizations: reRuneAppLocalizationsConfig,
  updatePolicy: const ReRuneUpdatePolicy(
    checkOnStart: true,
    periodicIntervalInHours: 2,
    periodicIntervalInDays: 3,
  ),
);

ReRuneUpdatePolicy only accepts periodic refresh intervals in whole hours or whole days. If both fields are set, they are combined into a single refresh cadence. A value that is zero or negative counts as not set, and setup logs it; the other value still applies, and when neither is valid, setup also logs that periodic refresh is off. On web, if the combined interval exceeds the supported timer limit, ReRune clamps it to the maximum supported delay (24 days and 20 hours) instead of throwing. On other platforms it is capped at 100000 days.

Trigger updates from UI actions

final result = await ReRune.checkForUpdates();
if (result.hasErrors) {
  // show error state
}

for (final locale in result.updatedLocales) {
  // Fetched OTA message values changed.
}
for (final locale in result.updatedLocaleNames) {
  // The successfully applied manifest display name changed.
}
for (final locale in result.removedLocales) {
  // The locale is no longer published in the manifest.
}

result.hasUpdates is true when fetched values, locale names, or publication membership changed. Changes to names alone and unpublishing do not emit ReRune.onFetchedTextsApplied; they increment ReRune.localizationsRevisionListenable when visible runtime state changes.

Provide your own cache store

If you need custom storage behavior, implement ReRuneCacheStore and pass it to ReRune.setup(cacheStore: ...). Custom stores must persist all ReRuneCachedLocaleBundle fields: the serialized locale document data and the applied manifest locale name, version, minimumDeltaBaseVersion, and url. Implement readLocaleBundle(...), writeLocaleBundle(...), and deleteLocaleBundle(...) for locale state. A custom write must not expose a candidate as committed when it throws; preserve the previous complete payload and metadata until the replacement succeeds.

Existing custom stores remain source compatible with variant selection. Override readSetting(...) and writeSetting(...) when the application uses persisted variants. A custom store that leaves the default methods in place supports runtime variant changes but reports an unsupported operation when persist: true is requested; a store that implements the interface can say the same by throwing an UnsupportedError from writeSetting(...). When a store that supports settings fails to save one, ReRune logs it, and the selection applies until the next setup or until the app restarts. An UnimplementedError, which platform plugins throw at runtime, counts as such a failed save.

Custom stores must return null from readManifest() or readLocaleBundle(...) only when the requested entry does not exist. If persisted state exists but cannot be read or decoded, the read must throw so ReRune can report a storage error and repair locale content through a complete request.

The default cache uses the new rerune_locale_cache_v1 namespace (locale_<locale>.json on IO platforms and _locale_<locale> preference keys on web). Entries in the former ota_localizations ARB namespace are ignored and are not migrated or deleted.

Troubleshooting

  • Target of URI hasn't been generated: run flutter gen-l10n, then dart run rerune (or dart run build_runner build).
  • Undefined name reRune...Config: generated .rerune.g.dart file is missing, stale, or imported from a wrong path.
  • Changed l10n keys/signatures: rerun both generators.
  • No MaterialLocalizations found for a language Flutter does not translate: pass ReRune.localizationsDelegates to MaterialApp, as shown in Languages Flutter or intl do not cover.

License

This package is available under the MIT License. See LICENSE.

Libraries

builder
rerune