By the strings.dev team — maintainers of native .xcloc and .xcstrings localization tooling for iOS.
Step 1 — Enable base internationalization
Localization only works once your project is internationalized. Select your project (not a target) in the navigator, open the Info tab, and find Localizations. Xcode enables Use Base Internationalization by default for new projects; confirm the checkbox is on. "Base" is your development language — the source everything else is translated from.
You don't add languages here yet. The String Catalog is where you'll manage them, so leave this list with just your base language for now.
Step 2 — Write localizable SwiftUI strings
SwiftUI is already localization-aware. Any string literal you pass to Text is treated as a LocalizedStringKey automatically — you don't wrap it in anything:
struct WelcomeView: View {
let userName: String
var body: some View {
VStack {
Text("Welcome back") // auto-localized
Text("Signed in as \(userName)") // interpolation → %@
Button("Save changes") { save() } // button titles too
}
}
}
For strings that live outside a view — model layer, notifications, anything not a SwiftUI control — reach for String(localized:) so it still gets extracted:
let title = String(localized: "Daily summary")
One caveat: interpolating a variable turns the literal into a format string. Text("Signed in as \(userName)") becomes the key Signed in as %@, and Xcode records %@ as an argument. That's what lets a translator move the placeholder to wherever the target language needs it.
Step 3 — Add the String Catalog
File → New → File → String Catalog, name it Localizable, and save it in your target. You now have Localizable.xcstrings.
Build the project once (⌘B). On build, Xcode scans your code for every Text(...), Button(...), and String(localized:), and populates the catalog with a row per unique string — keyed by the source text. This auto-extraction is the whole point: you never hand-maintain a list of keys. Add a new Text("Delete") next week, build, and it appears in the catalog automatically. Remove it, and Xcode marks the entry stale so you can prune it.
Each row shows a state: a green checkmark (translated), a yellow dot (needs review), or an empty circle (needs translation). Your base-language column fills in from the source text; every other language starts empty.
Step 4 — Add a language
In the String Catalog editor, click the + at the bottom of the language list and pick a locale — say Spanish (es) or French (fr). Xcode adds a column and seeds it with untranslated rows.
Prefer the regional variant when it matters: es-MX reads differently from es-ES, and pt-BR differs from pt-PT in vocabulary and tone. If you're unsure which BCP-47 code Xcode expects, the iOS + Android locale code reference lists them.
Step 5 — The manual fill (and why it doesn't scale)
Now the work: click each empty cell and type the translation.
| Key |
English (base) |
Spanish (es) |
Welcome back |
Welcome back |
Bienvenido de nuevo |
Save changes |
Save changes |
Guardar cambios |
Signed in as %@ |
Signed in as %@ |
Sesión iniciada como %@ |
Notice %@ is carried across untouched — the placeholder must survive translation exactly, or the app prints the wrong value (or crashes) at runtime. Keep going and you'll fill dozens of rows for one language. For each additional language you repeat the entire column. This is where a manual workflow stops being fun: 40 strings × 20 languages is 800 cells, and every new feature adds more.
Step 6 — Plurals with "Vary by Plural"
Counts are the part that breaks naïvely. English has two plural forms; Russian and Polish have several; Arabic has six. You cannot solve this with if count == 1.
In your SwiftUI code, interpolate the number normally:
Text("\(itemCount) items in cart")
Then in the String Catalog, right-click the row → Vary by Plural. Xcode expands that row into the Unicode CLDR categories (one, other, and more where a language needs them) and stores each variation in the catalog:
"%lld items in cart" : {
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : { "stringUnit" : { "value" : "%lld item in cart" } },
"other" : { "stringUnit" : { "value" : "%lld items in cart" } }
}
}
}
}
}
The OS picks the correct variation at runtime from the count and the locale's rules. The catch: a target language may need few and many forms your English source never defined, so filling plurals by hand means knowing each locale's CLDR categories — not just mirroring English's one/other.
Step 7 — Fill all 52 locales in one pass
Steps 5 and 6 are exactly the mechanical, repeatable work worth automating. This is where strings.dev fits in.
You don't change your Xcode workflow at all. When it's time to translate, export a native Xcode Localization Catalog per language — Editor → Export Localizations…, or on the command line:
xcodebuild -exportLocalizations \
-project MyApp.xcodeproj \
-localizationPath ./loc
That produces .xcloc bundles (each wrapping the same String Catalog data). Upload them to strings.dev, pick your target locales — up to 52, including the regional variants above — and it fills every empty cell and every plural category in one pass, returning the same native format you exported. No XLIFF conversion, no format massaging. Import the results back with Editor → Import Localizations… and the catalog rows flip to translated.
Because every string is translated with your app-description context and any per-string translation notes you attached, the output reflects what your app actually does — "Send" stays your app's mode, not a mistranslated verb. Placeholders (%@, %lld, %1$@), URLs, emails, and @handles are preserved automatically, and plural rows are expanded to each locale's real CLDR categories rather than copied from English.
The free Indie tier covers 1 project and 1 language with unlimited word translations — enough to run this whole loop for your first market. Going wider (unlimited languages, 2 projects, brand context, and a QA & analytics dashboard that flags failed translations for retry) is Indie Plus at $20/mo, or $10/mo billed annually.
Keeping it in sync
New features add strings on every release. Rather than re-exporting by hand, wire the translate step into your pipeline: strings.dev exposes per-project CLI scripts, a REST API (per-project key), and the strings-mcp-server MCP package plus a generated AI-skill prompt, so a git hook, CI job, or agent (Claude, Cursor, Copilot) can push changed strings and pull native translations back. Full setup is in the API and CLI docs. For the broader end-to-end process — RTL, pseudolocalization, market selection — see the mobile app localization guide.
One boundary to keep clear: this flow localizes in-app strings only. Your App Store Connect metadata (description, keywords, screenshots) is a separate task handled in App Store Connect, not in your String Catalog.