How to Localize an iOS App with Xcode String Catalogs
Set up Xcode String Catalogs, add languages, translate .xcstrings with AI, review the result, and ship a localized iOS app.
I localized Cube into 30 languages in an evening. The translations cost less than a coffee, the workflow lived entirely inside Xcode, and the only file under version control was a single Localizable.xcstrings.
A few years ago that would have been a multi-week project. Old-style .strings files, one per locale. A separate .stringsdict for plurals. A patchwork of shell scripts to export, send to a translation service, and import back. Every new key was a chance to forget a locale or break the build.
Xcode 15 introduced String Catalogs (.xcstrings). Xcode 16 made them the default for new projects. Combined with AI translation, they collapse the localization workflow for an indie iOS or macOS app from “multi-week project” to “afternoon task.”
This is the guide I wish I had when I started localizing my own apps. It walks through the full workflow: creating your first catalog, adding languages, handling plurals and device variants, translating, reviewing, and shipping. The examples are iOS but everything applies equally to macOS, watchOS, tvOS, and visionOS.
What is a String Catalog?
A String Catalog is a single JSON file that stores every localizable string in your app, across every language. By convention it lives at <Module>/Localizable.xcstrings and is the one file you ever need to touch for localization.
Under the hood it replaces three older formats at once:
.stringsfiles — the original key/value localization format, one file per language.stringsdictfiles — the XML format for pluralization rules- Localizable.strings.dict spread across
<Language>.lproj/folders — the mess this was supposed to solve
Open a .xcstrings file in any text editor and you’ll find a flat JSON map of keys to per-language values, with metadata for plurals, device variants, and extraction state. Xcode handles the UI side, but it’s plain JSON if you ever need to script it.
The headline features that make the old workflow look prehistoric:
- Automatic extraction. When you build, Xcode scans your code for localizable strings and adds them to the catalog. No
genstrings, no manual exports. - Native plurals. Right-click a string → “Vary by Plural” → ICU MessageFormat handles all 35+ plural rules across world languages.
- Device and width variants. “Settings” on iPhone vs. iPad, or in a compact width — same key, different value.
- Extraction state tracking. Xcode marks strings as “new”, “stale” (no longer in code), or “translated”, so you always know what changed.
- Plays nicely with version control. Diffs are readable; merge conflicts are rare and recoverable.
Creating your first catalog
In Xcode: File → New → File from Template → String Catalog (or Cmd-N → search “String Catalog”). The default name is Localizable.xcstrings. Drop it in your main app target.
That’s it for setup. Build the project once. Xcode walks your source code and adds every localizable string it finds.
What counts as “localizable”? Anything that uses the standard localization APIs:
// SwiftUI Text — auto-localizable
Text("Welcome to Cube")
// Explicit form — works anywhere
String(localized: "Welcome to Cube")
// With a stable key and a default value
String(
localized: "subscription.expired",
defaultValue: "Your subscription has expired"
)
// Legacy NSLocalizedString — still works
NSLocalizedString("Welcome to Cube", comment: "Greeting on launch screen")
The catalog will look like this after extraction:
KEY en de ru ja
Welcome to Cube Welcome to Cube — — —
subscription.expired Your subscrip… — — —
A few habits that pay off long-term:
- Use stable keys for non-trivial strings.
subscription.expiredsurvives a copy rewrite;"Your subscription has expired"doesn’t. When you change the English source text on a free-form key, every translation gets marked stale. - Always add a comment for ambiguous strings.
"Free"can mean “no cost” or “available now” — translators (human or AI) need that context. - Avoid string concatenation.
"Hello " + namereads fine in English and breaks in half the world’s languages. Use interpolation:"Hello \(name)"with localized format strings.
Adding languages
Languages live at the project level, not in the catalog itself.
Project → (Project node) → Info → Localizations → +
Pick a language. It appears as a new column in every catalog in your project. You can add as many as you want — there’s no per-language cost in Xcode (the AI translation bill is a separate matter; we’ll get there).
A few tips:
- Set your Source language explicitly. It’s the language you write the original strings in. Other languages are derived from it. Click the catalog → Inspector → Localization → Source.
- Don’t enable a language until you’re ready to ship it. Adding “Catalan” to a project that has zero Catalan strings is fine; releasing an app that ships with “Catalan” but no Catalan strings shows blanks to Catalan users.
- Languages can be removed. No drama.
For a typical indie app I recommend starting with this set, in order of App Store impact:
- English (your source)
- Spanish — Mexico, US Latino, Spain
- Portuguese (Brazil) — large market, often underserved
- German — high ARPU, low competition for indie apps
- French — France + francophone Africa + Canada
- Japanese, Korean, Chinese (Simplified) — three completely different markets, all valuable
- Russian, Italian, Turkish, Polish, Dutch — long tail
Most apps see ~15–25% of total revenue come from non-English locales once they’re properly localized.
Plurals
This is the part where everyone learns that the world is more complicated than English.
The English plural rule is simple: 1 photo, 2+ photos. Done. Russian has six plural forms. Arabic has six. Welsh has six. ICU MessageFormat covers them all, and .xcstrings speaks ICU.
To pluralize a string in Xcode:
- Click the row for the string in question
- Right-click → Vary by Plural → pick a variable name (
count) - Fill in the cases:
zero,one,two,few,many,other
For each language, you only fill in the cases that language actually uses. English uses one and other; Russian uses one, few, many; Welsh uses all six.
// In your code:
let count = photos.count
Text("^[\(count) photo](inflect: true)")
// Or with a stable key:
Text(String(localized: "photos.count", defaultValue: "\(count) photos"))
The compiler-level plural support in Xcode is one of the strongest reasons to use String Catalogs over hand-rolled localization. You will save yourself days of debugging “why is the Russian build showing ‘5 фото’ instead of ‘5 фотографий’” by leaning on it.
A gotcha: don’t write your own plural logic in Swift. I’ve seen:
let label = count == 1 ? "1 item" : "\(count) items" // Wrong in 30+ languages
That ships fine in English and silently breaks everywhere else. Use the catalog.
Device, width, and width variants
Same key, different display contexts.
- Vary by Device: different value on iPhone vs. iPad vs. macOS vs. Apple TV vs. Apple Watch.
- Vary by Width: different value in compact vs. regular horizontal size class. Useful when a string fits on iPad but truncates on a small iPhone.
Right-click → Vary by Device / Vary by Width.
A real example from Cube:
| Key | iPhone | iPad |
|---|---|---|
import.button | ”Import" | "Import String Catalog” |
This is the kind of polish that turns “translated” into “well-localized.”
Translating — your options
Once your catalog has all the source strings extracted and languages enabled, you need to fill the cells. Four approaches, ordered by cost:
Manual / community translation. Free if you have multilingual users. Slow. Best for one or two languages where you have a trusted reviewer. Falls over at scale.
Professional translation agency. $0.10–0.30 per word, depending on language and turnaround. Highest quality. For a 2,000-key catalog translated into 10 languages with ~3 words per key on average, you’re looking at $6,000–$18,000. For most indie apps that’s a non-starter.
Google Translate / DeepL. Fast and cheap, but they’re machine translation without app context. They don’t know that “Pro” is a product tier, not “professional.” They don’t preserve markdown. They struggle with format specifiers (%@, %lld).
AI translation (GPT / Claude / Gemini / DeepSeek). The recent shift. Quality between DeepL and human; cost between Google Translate and pennies. With a good prompt — context, glossary, tone — modern LLMs produce translations you can ship with a light review pass, not a full rewrite.
Concrete numbers I’ve measured on Cube itself:
- 2,415 keys × 30 locales (72,450 cells)
- Translated with GPT-4o-mini in 2 minutes 21 seconds
- API cost: $0.08 total
That’s not a typo. Eight cents.
That’s the cost shift that changed indie localization. You’re now bottlenecked by review, not translation.
Translating with AI in practice
Whatever tool you use, the workflow has the same five steps:
- Set up. Add your API key. For OpenAI, Anthropic, Google AI Studio, or DeepSeek — all four are usable; pick one based on price and the languages you care about.
- Define context. Tell the model what your app does in one sentence. Tell it the tone (friendly? clinical? indie-developer?). Give it a glossary of terms that must not be translated (your product name, technical terms).
- Translate. Batch through every empty cell.
- Review. Spot-check; have native speakers review the languages that matter most to your revenue.
- Save and commit. The catalog is a single file in git. The diff is reviewable.
Here’s what that looks like with Cube specifically. (I’m the developer, so this is the workflow I use — but the structure is the same with any of the alternatives in the competitive landscape.)
Open Cube. It auto-detects open Xcode projects and lists the catalogs inside each one. Pick the project. Pick the catalog. The translation grid appears — keys down, languages across.
Add your API key — Cube stores it in your macOS Keychain. No server side. The same key works across every project.
Pick the model per project. I use GPT-4o-mini for bulk translation (cheap, fast, good enough) and switch to Claude Opus 4 for languages where I have a native reviewer who’s flagged quality issues. Different projects can use different models; you’re not locked in.
Set localization rules:
- Tone: “indie developer, technical, concise”
- Glossary:
Cube,Xcode,.xcstrings,App Store Connect— never translate - Sample translations from a reference locale you trust
Hit Translate. Cube fans out a request per language, batches keys to fit the model’s context window, and writes back into the .xcstrings file in real time.
If you’re using the App Store Connect integration: open the same project’s App Store tab. Cube pulls the metadata (name, subtitle, description, keywords, promotional text, what’s new) and lets you translate it in the same workflow. App Store Connect changes are a separate API but the keystrokes and the LLM glossary are the same.
Commit the modified .xcstrings to git. Move on with your life.
Reviewing translations — don’t skip this
AI translation in 2026 is much better than the machine translation of 2018. It’s not good enough to ship blindly.
The failure modes I see most often:
- Wrong register. German has formal (Sie) and informal (du). Korean has multiple speech levels. AI defaults to formal/polite, which is right for a banking app and wrong for a casual indie tool.
- Hallucinated product features. Tell the AI your app does X. It might helpfully describe in the translation that it also does Y. Glossary + clear context + the AI proofreading pass catch most of these.
- Format specifier corruption.
"%lld items"becomes"%lld 件"in Japanese, which is correct. But sometimes the model turns%lldinto%lidor drops it. Always validate format specifiers automatically — Cube’s review pass does this, and so do most competing tools. - Cultural mismatch. A holiday-themed feature flag named “Halloween” doesn’t translate to anything meaningful in Japan. A “share to BeReal” button needs the original name. The glossary is your friend.
The honest minimum review process:
- Run the AI translation
- Run the AI review pass — most tools (Cube included) have a step where a stronger model audits the weaker model’s output and flags issues. Catches ~70% of problems automatically.
- For your top 3-5 revenue languages: find a native speaker. Pay them for an hour of their time. Have them skim the catalog with the app running. This costs $50–200 per language and catches the remaining 30%.
- For long-tail languages where you can’t easily get a reviewer: ship them with confidence around UI strings, hesitancy around marketing copy.
You can ship 30 languages at “good enough for App Store” within a week. You can’t ship 30 languages at fluent-native quality without reviewers. Be honest about which you’re doing.
Don’t forget App Store metadata
The .xcstrings file covers everything inside your app. There’s a parallel localization story for everything outside it — your App Store product page.
For every locale you ship, App Store Connect lets you provide:
- App name (30 chars)
- Subtitle (30 chars)
- Promotional text (170 chars, can update without a new build)
- Description (4,000 chars)
- Keywords (100 chars, comma-separated, hugely important for ASO)
- What’s New per version (4,000 chars)
These are not in the .xcstrings. They live in App Store Connect, accessible via API or the web UI. If you only localize the in-app strings and not the listing, your German users still see English on the store page and bounce.
Cube handles both in one workflow: same project, same glossary, same LLM, just a different tab. (This is the part that’s genuinely unique — most String Catalog translators stop at the .xcstrings boundary.)
Common pitfalls
A list of the ones I’ve personally hit:
Hardcoded strings outside the localization API. Error messages thrown by your old networking layer. Test fixtures. Logging strings that turned out to be user-facing. Xcode’s extraction catches String(localized:) and NSLocalizedString, but not bare "oops" literals. Audit with grep -rn '"' Sources/ once a year.
Concatenation. "Hello " + name breaks gender and word order in most non-English languages. Always use interpolation, and pull the whole sentence into the catalog.
Date and number formatting. "\(date)" formats with the user’s locale. "\(date.formatted())" does too. But let s = "\(year)-\(month)" doesn’t. Use FormatStyle and Locale.
Right-to-left. Arabic, Hebrew, Persian, Urdu. Mirror your UI. Test with Edit Scheme → Run → App Language → “Right to Left Pseudolanguage”. You’ll find layout bugs you didn’t know you had.
Length differences. German is ~30% longer than English on average. Test with the “Double-Length Pseudolanguage” option. Make sure buttons don’t truncate, that horizontal stacks wrap, that fonts can shrink.
Missing fonts. Your custom font may not have Cyrillic, Thai, or Japanese glyphs. Test with actual translated strings, not just pseudo-strings.
Stale strings. Every time you change the English copy of a free-form key, all translations get marked stale and reverted to source until re-translated. Use stable keys for non-trivial strings to avoid this.
Forgetting App Store screenshots. Your in-app text is localized but your screenshots still say “Welcome to Cube” in English on the Italian store page. Either localize screenshots (asc-shots-pipeline-style automation), or use generic visuals without text.
Wrap up
The combination of Xcode String Catalogs + AI translation + a 30-minute review pass per language is the workflow I’d recommend to any indie iOS or macOS developer who’s been putting localization off because it felt expensive.
It is no longer expensive. The technical infrastructure is in place. The translation cost is rounding-error. The remaining work is the review pass — and that’s the kind of work that’s actually worth doing, because it’s where you turn “machine-translated” into “well-localized.”
If you want to try Cube for the translation part, it’s free on the Mac App Store — you bring your own API key, your files never leave your Mac, and you can translate both the .xcstrings and your App Store metadata from the same project view.
Either way: ship your app in more languages. There are users waiting.