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:
- Device locale only — simple, but users with an English phone who want the app in Spanish are stuck.
- In-app picker only — ignores a perfectly good signal on first launch.
- 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; yourifdoes not.@ngx-translate/coresupports ICU via theTranslateMessageFormatCompiler;i18nexthas ICU support throughi18next-icu. - Give translators context. Ship a sibling comment file or use keys like
orders.actions.cancel.buttonso "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/right → inline-start/inline-end, top/bottom → block-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
Dateobjects 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:
- 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 youren.jsonproduces it. - 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.
- 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. - 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
- Extract strings and ship the English-only app on the i18n plumbing. No user-visible change, but every future string is now translatable.
- Convert CSS to logical properties and add the RTL smoke test — still no user-visible change.
- Switch all formatting to
Intl. - Add the language picker plus one pilot locale, behind a flag, to a beta group.
- Add the native strings and store listings for that locale, then release.
- 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.