Apple introduced String Catalogs (.xcstrings) back in Xcode 15 as a single JSON-backed file that replaced both Localizable.strings and Localizable.stringsdict. Xcode 26 upgrades the catalog format from version 1.0 to 1.1, and layers three major features on top:
- Generated String Catalog Symbols. A build setting turns every key in your catalog into a strongly typed
LocalizedStringResource property. Text(.cartCheckoutTitle) replaces Text("cart.checkout.title"), and a missing key becomes a compile error instead of a bug report.
- The
#bundle macro. It resolves to the correct bundle at the call site — main app, framework, or Swift package — removing the brittle Bundle.module plumbing that every package author has written at least once (guilty as charged).
- On-device comment generation. Xcode's on-device model reads the surrounding code and drafts translator comments that actually describe what the string is used for. Right-click a key, pick Generate Comment, done.
New projects created in Xcode 26 enable symbol generation by default. Existing projects opt in under Build Settings → Localization → Generate String Catalog Symbols.
Create a String Catalog From Scratch
So, let's start clean. Open any Xcode 26 project, choose File → New → File from Template…, filter for String Catalog, and save it as Localizable.xcstrings. Xcode scans the target on every build and adds any localizable string it finds. You don't run a script, call a generator, or edit JSON by hand.
SwiftUI picks up strings automatically from Text, Button, Label, toolbars, navigation titles, and accessibility modifiers. Outside SwiftUI, use String(localized:):
import SwiftUI
struct CartView: View {
let itemCount: Int
var body: some View {
VStack(spacing: 16) {
Text("Your Cart") // auto-extracted
Text("\(itemCount) items") // auto-extracted with arg
Button("Checkout") { checkout() } // auto-extracted
}
}
private func checkout() {
let confirmation = String(localized: "Order confirmed")
print(confirmation)
}
}
After the first build the catalog shows three keys: Your Cart, %lld items, and Checkout. Add languages with the + button in the inspector, fill in the translations, and ship.
Turn On Type-Safe Generated Symbols
This is the feature worth migrating for. Enable Generate String Catalog Symbols in build settings (it's on by default for new Xcode 26 projects) and Xcode produces an internal Swift namespace from your catalog. Keys become properties on LocalizedStringResource, and the compiler enforces argument types.
// Before — a typo silently falls back to the key at runtime
Text("cart.chekout.title")
// After — the compiler rejects the typo
Text(.cartCheckoutTitle)
// Format strings are generated as functions with typed arguments
Text(.itemsInCart(count: itemCount))
// Works anywhere LocalizedStringResource is accepted
let subtitle = String(localized: .cartSubtotal(amount: 42.00))
Label(.checkout, systemImage: "creditcard")
Xcode derives symbol names from keys by stripping dots and camel-casing what's left. A key of App.HomeScreen.Title becomes .appHomeScreenTitle. For tables other than Localizable, the symbols live in a scoped namespace — a key in Onboarding.xcstrings is accessed via .Onboarding.welcomeTitle. It's a small thing, but namespacing by table really does keep auto-complete usable.
Migrate an Existing Codebase With One Click
If you already have a project full of raw string keys (who doesn't?), Xcode 26 ships a refactoring action to convert them in bulk. Select any localized string in the editor, then Refactor → Convert Strings to Symbols. A preview pane lets you rename generated symbols before committing the change, and you can batch an entire table in one pass.
Pluralization Without .stringsdict
The single best reason .xcstrings replaced .stringsdict is plurals. The old XML format was, frankly, miserable to edit by hand. The new variations UI handles CLDR plural categories directly in the catalog editor. Right-click a key with a numeric argument and choose Vary by Plural. Xcode exposes the exact plural forms each language requires — one for English, six for Arabic, four for Polish and Russian — and you fill them in.
// Pluralization is driven by interpolation — the argument MUST be passed
// through the format string, or the system falls back to the base.
Text("\(itemCount) items in cart")
// Generated symbol equivalent
Text(.itemsInCart(count: itemCount))
If you skip interpolation and build the count into the string yourself (Text("\(count) items in cart" as String)), SwiftUI has no argument to inspect and pluralization quietly breaks. This is the number-one support question on the Apple forums — always let the format string carry the count.
Device and Width Variants
The variations panel also handles device-specific copy. Right-click a key, choose Vary by Device, and provide different text for iPhone, iPad, Mac, Apple Vision, and Apple Watch. Combined with plural variation, you can express "1 landmark" on iPhone and "One landmark in your collection" on iPad without branching at the call site. That's the kind of thing that used to require a whole utility layer.
The #bundle Macro: Framework Authors Rejoice
Before Xcode 26, any framework or Swift package that wanted its own translations had to pass bundle: .module on every call:
// The old incantation — easy to forget, breaks silently
public struct ChartView: View {
public var body: some View {
Text("Revenue", bundle: .module)
}
}
The new #bundle macro expands to the correct bundle at the call site — whether you're in the main app target, a framework, or a Swift package:
import SwiftUI
public struct ChartView: View {
public var body: some View {
// #bundle resolves to the package's own resource bundle
Text("Revenue", bundle: #bundle)
}
}
// Equivalent usage with generated symbols
Text(.revenue)
One gotcha worth flagging: generated symbols for Swift Packages are internal by default, which means a package can't expose its own keys to consumer apps. If you maintain a UI library whose strings should be overridable, keep a small public facade that takes LocalizedStringResource parameters and let the host app supply its own keys.
Generate Translator Comments With On-Device AI
Comments are how translators disambiguate "Open" the verb from "Open" the adjective. Writing them is tedious, which is why they're usually missing. (Raise your hand if you've shipped an empty-comment catalog. Same.) Xcode 26 runs an on-device model that reads the call site and drafts a comment. Right-click a string in the catalog editor and choose Generate Comment:
// Input code
Button("Open") { document.open() }
// Auto-generated comment
// "Button label that opens the currently selected document."
You can accept, edit, or regenerate the suggestion. Nothing leaves the device, so the feature works on internal or embargoed projects without legal review breathing down your neck.
Migrate From .strings and .stringsdict
Open any legacy Localizable.strings or Localizable.stringsdict in the project navigator, right-click it, and choose Migrate to String Catalog…. Xcode shows a dialog listing every migratable file and produces a single .xcstrings that preserves your translations, plural rules, and comments. You can migrate one table at a time — Xcode reads both formats during the same build, so incremental migration is safe.
Verify the Localization Prefers String Catalogs build setting is set to Yes, then delete the old files only after running your UI tests under pseudo-localization (Edit Scheme → Run → App Language → Double-Length Pseudolanguage) to catch layout and argument issues. Trust me — the double-length pass always surfaces at least one truncated button.
CI/CD: Fail the Build on Missing Translations
String Catalog symbol generation pairs naturally with CI. Because missing keys become compile errors, simply building your target on CI covers a whole class of localization bugs. For missing translations — keys that exist but haven't been translated into a shipping language — add an xcodebuild step that exports XLIFF and validates coverage:
#!/usr/bin/env bash
set -euo pipefail
xcodebuild \
-scheme MyApp \
-configuration Release \
-destination "generic/platform=iOS" \
-exportLocalizations \
-localizationPath build/loc \
-exportLanguage en \
-exportLanguage de \
-exportLanguage ja
# Fail the job if any language has any untranslated keys
python scripts/check_xliff_coverage.py build/loc
A tiny Python script that parses the exported XLIFF for empty <target> elements is enough to gate merges on translation completeness. Pair it with a job that posts the untranslated keys to your translation vendor's API and you've got a closed loop.
Swift Package Best Practices
Localizing a Swift Package in Xcode 26 is finally a one-liner in Package.swift:
// swift-tools-version: 6.1
import PackageDescription
let package = Package(
name: "ChartKit",
defaultLocalization: "en",
platforms: [.iOS(.v17)],
products: [
.library(name: "ChartKit", targets: ["ChartKit"])
],
targets: [
.target(
name: "ChartKit",
resources: [.process("Resources")] // contains Localizable.xcstrings
)
]
)
Inside the package, use #bundle or generated symbols — never hard-code Bundle.module. If consumers of your package need to override your strings, expose a public initializer that accepts LocalizedStringResource parameters and let them pass their own values from the host app.
Git Hygiene and Merge Conflicts
A String Catalog for an app with five languages and 500 keys can easily reach 10,000 lines of JSON. Conflicts happen, and the file format is unforgiving — a conflict marker dropped inside the JSON renders the whole catalog unparseable. Three habits keep the pain low:
- One merge at a time. Whoever merges first wins; the second PR rebases and re-runs a build to regenerate keys. Avoid long-lived branches that touch the catalog.
- Split by feature table. Instead of one monolithic
Localizable.xcstrings, use Onboarding.xcstrings, Checkout.xcstrings, and Settings.xcstrings. Fewer cross-feature conflicts, and symbol namespaces stay tidy.
- Treat translations as source. Don't regenerate the catalog from scratch just to "clean up" — you'll lose translator context. Use the migrate action once, then edit.
Common Pitfalls
- Breaking pluralization with string concatenation. Always pass the count through interpolation.
Text("\(count) items") works; Text("\(count)" + " items") does not.
- Stale extraction state. If you rename a key in code, Xcode marks the old entry Stale. Delete stale keys or they ship in your binary forever.
- Missing
#bundle in a package. Generated symbols only resolve against the package bundle if the catalog is a package resource. Confirm resources: [.process(...)] is set.
- Assuming symbol names are stable. Renaming a key in the catalog editor regenerates the symbol. Use refactoring actions to rename — don't edit the JSON by hand.
- Relying on format version 1.1 in a mixed-tooling team. Xcode 15–25 can't read 1.1 files. If any teammate hasn't upgraded, keep Generate String Catalog Symbols off until everyone updates.
When to Skip Generated Symbols
Generated symbols are internal, which is the right default for an app target but wrong for a few specific cases:
- Server-driven UIs where string keys arrive over the network. The server doesn't know your generated symbol names, so you fall back to
String(localized:) with a runtime key.
- Shared translation tables consumed by multiple apps or packages. Internal visibility means you can't expose the symbols; stick to plain
LocalizedStringResource values until Apple adds a public-visibility option.
- Design systems where translators add new strings without touching Swift. The catalog editor is designer-friendly, but enabling symbol generation couples translators to the Swift build. Keep the catalog translator-owned and let developers request new keys through the editor UI.
Frequently Asked Questions
Are String Catalogs backward compatible with iOS 16?
Yes. The .xcstrings format was introduced in Xcode 15 and ships compiled down to standard .strings and .stringsdict resources inside the app bundle at build time. You can deploy to iOS 13+ without changing your deployment target. Xcode 26 features like generated symbols are compile-time tools, so they don't affect the minimum OS you can target.
Do I still need NSLocalizedString?
No. String(localized:) supersedes it for all new code and carries the same localization metadata. NSLocalizedString still works for legacy compatibility, but it doesn't participate in generated-symbol extraction the same way, so mixing them weakens type safety. Migrate where practical.
How do I localize strings that come from a backend?
Generated symbols don't fit dynamic keys by design. Use String(localized: LocalizedStringResource(stringLiteral: key)) when the key is produced at runtime, and have the backend return a localization key rather than a translated string. The client then maps the key through the catalog, and the user still gets their device language.
Can I customize a generated symbol's name?
Yes. The Refactor → Convert Strings to Symbols preview lets you rename symbols before committing. You can also edit the substitutions section of the .xcstrings file directly to set a specific symbol name, although the refactor UI is safer.
Why does my plural variation fall back to the base string?
Almost always because the count argument is computed outside the format string. SwiftUI can only select a plural rule when it sees the integer at formatting time. Wrap the count inside the interpolated string: Text("\(count) items") — not Text("\(String(count)) items").
Ship It
If you're starting a new app in Xcode 26, accept the defaults — generated symbols on, format 1.1, one Localizable.xcstrings per feature table — and never type a raw string key again. For existing apps, the migration path is two build settings and one refactor action away, and you can roll it out one feature at a time without breaking anyone else's work.
The payoff, in my experience? A localization pipeline where typos fail the build, translators get real context for their strings, and Swift Packages localize themselves with a one-line manifest. After a decade of stringly typed pain, that feels like progress worth shipping.