By the strings.dev team — maintainers of native .xcloc and strings.xml localization tooling for iOS and Android.
i18n vs l10n: the difference that actually matters
You'll see two abbreviations everywhere, and confusing them causes real bugs:
- Internationalization (i18n) is engineering work you do once: pull every user-facing string out of your code, wire up plural handling, and make layout adapt to any language length or direction. It happens before you have a single translation.
- Localization (l10n) is the per-locale work you do repeatedly: translating those strings, adapting formats, and shipping updates as your app changes.
The order is non-negotiable. If your app isn't internationalized — if strings are hardcoded in your views — no amount of translation will make it work. Get i18n right first, then mobile app localization becomes a data pipeline instead of a rewrite.
Externalizing strings (the foundation)
Every localizable string must live in a resource file, referenced by a key, never inlined in a Text("Save") or button.setText("Save") call. This is the single highest-leverage step in the whole process.
iOS: String Catalogs first
As of 2026, the modern Apple default is the String Catalog (.xcstrings) — a single JSON-backed file Xcode manages, with a visual editor, per-string state tracking, and built-in plural/device variations. Start here for new apps:
// Code references a key; Xcode extracts it into Localizable.xcstrings
Text("welcome_title")
String(localized: "cart_item_count")
For handing strings out to translators (or a localization service) and getting them back, Xcode exports an Xcode Localization Catalog (.xcloc) per language. That's the native round-trip format: same format in, same format out, no XLIFF conversion step to corrupt your data. This is exactly the boundary strings.dev automates — you upload the .xcloc, pick target locales, and get native .xcloc back.
Android: strings.xml first
Android's model is resource directories. Your base strings live in res/values/strings.xml; each locale gets a parallel res/values-<locale>/strings.xml:
<!-- res/values/strings.xml -->
<resources>
<string name="welcome_title">Welcome</string>
<string name="save_button">Save</string>
</resources>
<!-- res/values-de/strings.xml -->
<resources>
<string name="welcome_title">Willkommen</string>
<string name="save_button">Speichern</string>
</resources>
The folder naming is the contract: values-es, values-es-rMX, values-pt-rBR, values-fr-rCA. Get the qualifiers right and Android's resource system picks the correct file at runtime.
A note on scope: this guide — and strings.dev — is about in-app strings. Your App Store Connect and Google Play listing metadata (descriptions, screenshots, keywords) is a separate translation task handled in the store consoles, not in your resource files. Don't conflate the two.
Choosing target languages and markets
More languages isn't automatically better — each one is ongoing maintenance. Prioritize with data you already have:
- Where your users already are. App Store Connect and Play Console analytics show installs and revenue by country. Localize where you have traction or clear upside.
- Coverage per unit of effort. A handful of locales — Spanish, Portuguese, German, French, Japanese, Simplified Chinese — covers an outsized share of the global paying mobile market.
- Regional variants where they matter.
es-ES vs es-MX and pt-BR vs pt-PT differ in vocabulary and tone; zh-Hans vs zh-Hant are different scripts. Collapsing them reads as careless to native speakers. See the full supported locale list for the variants worth splitting.
Practically: start with one language, validate the pipeline end to end, then expand. The free Indie tier on strings.dev covers exactly that — 1 project, 1 language, unlimited word translations. Going wider (unlimited languages, 2 projects) is Indie Plus at $20/mo, or $10/mo billed annually.
Placeholders and formatting
Interpolated values are where naive translation breaks apps. The formats differ by platform:
| Platform |
Positional example |
Notes |
| iOS |
%@, %lld, %1$@ |
%@ for objects/strings, %lld for integers |
| Android |
%s, %d, %1$s |
Mark reorderable strings formatted="false" only when needed |
The rule: placeholders must survive translation untouched, and translators must be free to reorder them. German and Japanese frequently need a different word order than English, which is why positional forms (%1$@, %2$s) exist — they let the translation move the argument without breaking it.
<!-- Positional lets the translator reorder subject and count -->
<string name="msg_summary">%1$s sent you %2$d files</string>
Brand names, URLs, emails, @handles, and proper nouns should not be translated at all. strings.dev preserves placeholders, URLs, emails, and handles automatically, and lets you mark brand phrases as protected so "Send" (your product's mode) never becomes a verb in French.
Plurals: the part everyone gets wrong
"1 item" vs "2 items" is trivial in English and brutal across languages. Arabic has six plural categories; Russian and Polish have distinct forms for numbers ending in 2–4 vs 5+. You cannot solve this with if (count == 1). Use the platform plural systems, which follow the Unicode CLDR categories (zero, one, two, few, many, other):
iOS — String Catalog plural variations, edited visually in Xcode and stored per-string in .xcstrings. You provide the variations; the OS selects the right one for the locale's rules.
Android — <plurals> quantity strings:
<plurals name="cart_item_count">
<item quantity="one">%d item in cart</item>
<item quantity="other">%d items in cart</item>
</plurals>
The critical insight: a target language may need few and many forms that your English source never defined. A localization step that understands CLDR categories will generate the forms each language actually requires — not just mirror English's one/other. strings.dev handles both String Catalog plural variations and Android <plurals> in the native files, expanding categories per locale.
RTL basics
Arabic, Hebrew, Farsi, and Urdu render right-to-left, and RTL is more than mirrored text — it flips your entire layout: navigation, back buttons, progress bars, icons with direction.
- iOS: use Auto Layout with leading/trailing constraints (never left/right) and system-standard back navigation. Most mirroring is automatic if you avoided absolute positioning.
- Android: set
android:supportsRtl="true" in the manifest and use start/end attributes instead of left/right.
- Test by forcing an RTL locale (or the RTL pseudolocale, below) even before you have real translations.
Testing and QA: pseudolocalization
Don't wait for real translations to find i18n bugs. Pseudolocalization replaces your source strings with accented, expanded, bracketed versions — [Ŝà弾vé…] — that stay readable while stress-testing layout. It surfaces three classes of bug instantly:
- Truncation — languages like German and Finnish often run considerably longer than English; pseudo-expansion catches clipped buttons.
- Hardcoded strings — anything that doesn't get pseudolocalized is still hardcoded in your code. This is the fastest hardcoded-string detector there is.
- Layout breaks — RTL pseudolocales reveal mirroring problems before a real Arabic build.
Both platforms support pseudolocales natively (Xcode scheme options; Android developer-options / en-XA and ar-XB). Run them in CI on every build.
For real translations, QA means catching regressions: a placeholder dropped in one locale, a plural category left empty, a translation that silently failed. On strings.dev, that's the QA & analytics dashboard, part of Indie Plus ($20/mo, or $10/mo billed annually), which flags failed translations for retry so a broken string doesn't reach a store build.
Shipping continuously: automate the loop
Localization isn't a one-time project — every feature adds strings, and a manual export/email/import cycle guarantees your translations lag behind your code. The goal is continuous localization: new source strings get translated as part of your normal pipeline.
strings.dev exposes the same loop four ways so it fits whatever you already run:
- CLI — per-project scripts you call from a git hook or CI job to push changed strings and pull translations.
- REST API — a per-project key for custom automation.
- MCP server — the
strings-mcp-server package plus a generated AI-skill prompt, so an agent (Claude, Cursor, Copilot) can run the entire translate-and-merge loop.
- Git hooks / CI — wire the CLI into a pre-push hook or a CI stage so a PR that adds strings comes back with translations.
# Sketch: a CI step that keeps translations in sync
- name: Localize changed strings
run: strings push && strings pull
Every translation is fed your app-description context and per-string translation notes, so the output reflects what your app actually does — not dictionary-literal guesses. Full setup lives in the API and CLI docs. More platform-specific walkthroughs are in the guides hub.
App localization checklist
Nail the internationalization foundation once, then let a native-format pipeline carry the localization on every release. That's the difference between "we translated the app once in 2024" and an app that ships in every language, every build.