+1 (415) 843-4662

Internationalizing Ionic apps: i18n, RTL, Intl formatting and localized native strings

Shipping an Ionic app in a second language is one of those jobs that looks like a week of work and turns into a quarter. The translation files are the easy part. What actually bites is everything around them: a layout that breaks the first time it meets Arabic, dates that read 03/04 and mean two different days in two markets, plurals that only work in English, native permission prompts and push notifications that stay stubbornly in the developer's language, and store listings nobody remembered to localize.

This tutorial takes an Ionic + Capacitor app from single-locale to properly international: runtime translation, RTL that actually works, locale-aware formatting, localized native strings and push payloads, and a testing pass you can run before every release. Examples are Ionic Angular where framework code is needed; the Capacitor and CSS parts are identical for React and Vue, and the React equivalents are noted inline.

Decide the locale model first

Before touching code, answer two questions, because they determine everything downstream.

Where does the locale come from? Three options, and you almost always want the third:

  1. Device locale only — simple, but users with an English phone who want the app in Spanish are stuck.
  2. In-app picker only — ignores a perfectly good signal on first launch.
  3. Device locale as the default, overridable by an in-app picker that persists. This is what we ship.

Runtime translation or build-time (one bundle per locale)? Angular's built-in @angular/localize compiles a separate bundle per locale, which is fastest at runtime but means a locale switch requires a full reload and your CI produces N builds. For a mobile app where users switch rarely but native code needs the current locale anyway, runtime translation with @ngx-translate/core (Angular) or i18next + react-i18next (React) is simpler to operate. That is the path below.

1. Resolve the locale at startup

Capacitor gives you the device language without a plugin dependency spiral:

npm i @capacitor/device @capacitor/preferences @ngx-translate/core @ngx-translate/http-loader
npx cap sync
// src/app/i18n/locale.service.ts
import { Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Device } from '@capacitor/device';
import { Preferences } from '@capacitor/preferences';

export const SUPPORTED = ['en', 'es', 'de', 'ja', 'ar'] as const;
export type Locale = (typeof SUPPORTED)[number];
const RTL: Locale[] = ['ar'];
const KEY = 'app.locale';

@Injectable({ providedIn: 'root' })
export class LocaleService {
  readonly locale = signal<Locale>('en');
  readonly dir = signal<'ltr' | 'rtl'>('ltr');

  constructor(private translate: TranslateService) {}

  async init(): Promise<void> {
    const stored = (await Preferences.get({ key: KEY })).value as Locale | null;
    const device = (await Device.getLanguageTag()).value;   // e.g. "es-419"
    await this.apply(stored ?? this.negotiate(device));
  }

  /** Pick the best supported locale for a BCP 47 tag; never throw. */
  private negotiate(tag: string): Locale {
    const lower = (tag || 'en').toLowerCase();
    const exact = SUPPORTED.find((l) => lower === l);
    if (exact) return exact;
    const base = lower.split('-')[0];
    return SUPPORTED.find((l) => l === base) ?? 'en';
  }

  async apply(locale: Locale, persist = false): Promise<void> {
    await this.translate.use(locale).toPromise();
    this.locale.set(locale);
    this.dir.set(RTL.includes(locale) ? 'rtl' : 'ltr');

    document.documentElement.lang = locale;
    document.documentElement.dir = this.dir();

    if (persist) await Preferences.set({ key: KEY, value: locale });
  }
}

Call init() before the first screen renders — in an APP_INITIALIZER (Angular) or before createRoot() resolves (React). A flash of untranslated English is a bug reviewers notice.

Set dir on <html>, not on a wrapper div. Ionic components read the document direction to flip their own internals (back button chevrons, ion-item detail arrows, slide gestures), and they only do that reliably when the attribute is at the document root.

2. Translation files that survive a translator

Keep one JSON file per locale under src/assets/i18n/, keyed by feature, not by English string:

{
  "orders": {
    "title": "Your orders",
    "empty": "No orders yet",
    "itemCount": "{count, plural, =0 {No items} one {# item} other {# items}}",
    "placedOn": "Placed on {date}"
  }
}

Three rules that prevent most retranslation cycles:

  • Never concatenate. "You have " + n + " items" is untranslatable — word order differs per language. Use one key with placeholders.
  • Use ICU MessageFormat for plurals, not an if (n === 1) in the template. Arabic has six plural categories; Japanese has one. ICU handles both; your if does not. @ngx-translate/core supports ICU via the TranslateMessageFormatCompiler; i18next has ICU support through i18next-icu.
  • Give translators context. Ship a sibling comment file or use keys like orders.actions.cancel.button so "cancel" as a verb is not confused with "cancel" as a noun.

In templates:

<ion-title>{{ 'orders.title' | translate }}</ion-title>
<p>{{ 'orders.itemCount' | translate: { count: items().length } }}</p>

Add a lint step so nobody commits a hardcoded string: an ESLint rule (@angular-eslint/template/no-interpolation-in-attributes won't do it — use eslint-plugin-i18next or a custom template rule) plus a CI script that diffs key sets across locale files and fails on missing keys. A missing key that silently renders orders.title in production is the most common localization defect we find in audits.

3. Make RTL actually work

This is where most Ionic apps fail. The fix is mostly CSS discipline.

Replace every physical property with a logical one.

/* before */
.card { margin-left: 16px; padding-right: 8px; text-align: left; border-left: 2px solid; }

/* after */
.card {
  margin-inline-start: 16px;
  padding-inline-end: 8px;
  text-align: start;
  border-inline-start: 2px solid;
}

left/rightinline-start/inline-end, top/bottomblock-start/ block-end. Every browser engine an Ionic app ships to supports these. A one-off grep -rn "margin-left\|margin-right\|padding-left\|padding-right\|text-align: *\(left\|right\)" src/ usually finds the whole list in a few minutes.

Flip directional icons, not all icons. A chevron and a "send" arrow must mirror; a logo, a play button on a video, and a clock must not.

[dir='rtl'] ion-icon.directional { transform: scaleX(-1); }

Watch transforms and gestures. Anything hand-rolled with translateX(-100%) for a drawer or a carousel needs a sign flip in RTL. Prefer Ionic's own components (ion-menu, ion-segment) which already handle it.

Numbers and code stay LTR. Phone numbers, IBANs and order references inside RTL text can reorder visually. Wrap them:

<span dir="ltr" class="ltr-inline">{{ order.reference }}</span>

Don't forget native. Android needs android:supportsRtl="true" on <application> in AndroidManifest.xml; iOS mirrors automatically once the language is in your localizations list (step 5).

4. Dates, numbers and currency

Never hand-format. Intl is available in every WebView you support and knows more about locales than any helper you will write.

export function formatDate(d: Date, locale: string): string {
  return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d);
}

export function formatMoney(cents: number, locale: string, currency: string): string {
  return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(cents / 100);
}

export function formatRelative(from: Date, locale: string): string {
  const mins = Math.round((from.getTime() - Date.now()) / 60000);
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  if (Math.abs(mins) < 60) return rtf.format(mins, 'minute');
  return rtf.format(Math.round(mins / 60), 'hour');
}

Two rules worth writing on the wall:

  • Currency is not a locale. A German user paying in USD gets locale: 'de-DE', currency: 'USD'. Never infer currency from language.
  • Store UTC, format locally. Send ISO-8601 with an offset from the API, keep Date objects in UTC, and format only at the edge. Time zone bugs in mobile apps are almost always a string that got formatted twice.

For sorting names or search results, use Intl.Collator(locale) rather than Array.sort()'s default code-point ordering — otherwise "Ärger" sorts after "Zebra" for your German users.

5. Localize the native layer

The web layer is now clean, but the OS still speaks English in three places.

Permission prompt strings (iOS). Create ios/App/App/<lang>.lproj/InfoPlist.strings for each language:

/* ios/App/App/es.lproj/InfoPlist.strings */
"NSCameraUsageDescription" = "Usamos la cámara para escanear recibos.";
"NSLocationWhenInUseUsageDescription" = "Mostramos las tiendas cercanas.";

Add each language to the project's localizations in Xcode (Project → Info → Localizations) — this is also what switches iOS into mirrored RTL layout for Arabic.

App name and Android strings. Add android/app/src/main/res/values-es/strings.xml, values-ar/strings.xml, and so on, with app_name and any native-side labels.

Push notifications. Localize on the server, not the client — the payload is what the OS shows on the lock screen and your JS is not running. Store each user's locale on their device token record at registration:

await api.post('/devices', {
  token,
  platform: Capacitor.getPlatform(),
  locale: localeService.locale(),   // update this when the user switches
});

Then build the notification body in that locale server-side. (iOS also supports loc-key/loc-args in the APNs payload, which pulls strings from your app bundle — useful when the server has no translation catalogue.)

Store listings. App Store Connect and Play Console both take per-locale titles, descriptions and screenshots. If you use fastlane, keep them in fastlane/metadata/<locale>/ and let CI upload them so they can never drift from the build.

6. Test it before the translators do

Add these four checks to the release checklist:

  1. Pseudo-localization. Generate a fake locale (en-XA) where every string becomes [!!! Ÿöür örðérs !!!] — 40% longer with accents. Run the app in it and screenshot every screen. Every truncation and overflow shows up in one pass, before real translations exist. A 30-line script over your en.json produces it.
  2. RTL smoke test. Run the whole app in Arabic (or pseudo-RTL) and walk the primary flows. Check back buttons, swipe-to-go-back, toasts, modals and any custom drawer.
  3. Key coverage in CI. Fail the build if any locale file is missing a key present in en.json, or contains a key that no longer exists.
  4. Automated screenshots per locale. If you already run Maestro or Playwright, loop the flows over your locale list and store the images as build artifacts. Layout regressions in Japanese are much cheaper to catch here than in a store review.

Also set the long-string test in your design review: German UI strings run roughly 30% longer than English, and Japanese line-breaks in places Latin text does not. Buttons with fixed widths and single-line ion-labels are the usual casualties — white-space: normal on labels and flexible button widths solve most of it.

A rollout order that works

  1. Extract strings and ship the English-only app on the i18n plumbing. No user-visible change, but every future string is now translatable.
  2. Convert CSS to logical properties and add the RTL smoke test — still no user-visible change.
  3. Switch all formatting to Intl.
  4. Add the language picker plus one pilot locale, behind a flag, to a beta group.
  5. Add the native strings and store listings for that locale, then release.
  6. Add remaining locales — by now each one is a translation file and a store listing, not an engineering project.

Doing it in this order means the expensive, app-wide refactors land while the app still has one language and one set of screenshots to verify. Teams that start at step 4 end up doing steps 1–3 twice.

Where teams get stuck

The recurring pattern in the audits we run: the app was localized once, in a rush, and then the plumbing decayed. New features shipped with hardcoded strings because there was no lint rule; the Arabic build broke because someone added margin-left; the push notifications stayed English because the locale was never stored with the device token. None of those are hard problems — they are each a CI check away from never happening again.

If you are planning a multi-market launch and want a second pair of eyes on the locale model, the RTL pass or the store metadata pipeline, our senior Ionic consultants do this work regularly — get in touch and tell us which markets you are opening.