iOS localization across app targets can break when a project becomes modular. The main app shows the translated button, but a widget, extension, or Swift package displays the source key. Copying the same entry into every String Catalog may fix that screen, but it leaves several catalogs to drift independently.
Make localization ownership explicit. Each string belongs to the module that presents it. Put truly shared interface text in one Swift package that owns the resource. Every lookup names the owning bundle, and release checks exercise each target instead of treating the main app as proof that the whole product works.
Why translated strings disappear across targets
A String Catalog is a build resource, not a repository-wide dictionary. Xcode processes a catalog for the targets that include it. A package resource is bundled with that package. An app extension has its own product and resource boundary. A key visible to the main application is therefore not automatically visible to code running from another bundle.
Apple's guide to localizing and varying text with a String Catalog explains how Xcode discovers strings, tracks their translation state, and stores variations. That workflow is catalog-specific. It does not turn separate catalogs into one implicit lookup space. Our guide to iOS app localization covers the single-target basics this article builds on.
Four assumptions cause most of these failures:
- A shared package calls a localization API without specifying its resource bundle.
- A catalog exists in the repository but is not included in the target that needs it.
- Several targets own copies of a common key, and only one copy receives the latest translation.
- A package has localized resources but lacks a clear default localization or runtime test.
A practitioner trying to reuse a key such as ok_button_title across two packages reported this boundary. The Stack Overflow question about sharing String Catalog translations describes a lookup from the main bundle that returns text but does not create or manage the package's catalog entries. Runtime lookup and source extraction are different operations.
Choose one owner for every message
Start with an ownership table before moving files. Do not begin by creating a global Common.xcstrings catalog. A large common catalog quickly becomes a dumping ground that couples unrelated features.
Use these rules:
- App shell text belongs to the main app target.
- Text used only by a widget or another extension belongs to that extension.
- Feature-specific text belongs to the package or target that implements the feature.
- Text rendered by several modules belongs in a deliberately shared UI package.
- Platform-owned text, such as system permission copy, remains in the resource location required by the platform.
The owner controls the source phrase, translator context, variants, review state, and removal decision. Callers request a localized value through the owner's API instead of reaching into its catalog by filename.
Record the decision in an ownership table:
| Message | Presenting surfaces | Owner | Lookup bundle |
|---|---|---|---|
account.signOut |
App settings | App shell | Main app |
trip.arrivesIn |
App and widget | Shared trip UI package | Package bundle |
widget.openApp |
Widget only | Widget extension | Widget bundle |
checkout.pay |
Checkout screens | Checkout package | Package bundle |
If a phrase happens to be identical in two unrelated features, that alone does not make it shared. Those messages may diverge later. Share the key only when the product intends one owner and one review history.
Put shared UI text in a resource-bearing package
Swift Package Manager supports localized resources as package-owned resources. The implemented Swift Evolution proposal for localized package resources defines a package defaultLocalization, localized resource layout, processing, and access through the package resource bundle. Apple's documentation also covers bundling resources with a Swift package.
A package that owns shared text can declare its default localization and process its resource directory:
// Package.swift
import PackageDescription
let package = Package(
name: "SharedTripUI",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
.library(name: "SharedTripUI", targets: ["SharedTripUI"])
],
targets: [
.target(
name: "SharedTripUI",
resources: [.process("Resources")]
)
]
)
Keep Localizable.xcstrings under the package's processed resources. Confirm that Xcode shows the catalog under the package target rather than only under the app project. The package should expose typed or namespaced accessors so callers do not need to know the table name or bundle.
import Foundation
public enum TripCopy {
public static func arrivesIn(minutes: Int) -> String {
String(
localized: "trip.arrivesIn \(minutes)",
table: "Localizable",
bundle: .module,
comment: "Arrival estimate shown in the app and trip widget"
)
}
public static var openTrip: String {
String(
localized: "trip.open",
table: "Localizable",
bundle: .module,
comment: "Button that opens the current trip"
)
}
}
The resource owner is explicit in this example. The app and widget import SharedTripUI and call TripCopy; they do not duplicate trip.arrivesIn in their own catalogs. Keep access control narrow enough that callers use the package API rather than inventing new raw-key lookups.
Keep target-specific strings with their target
Not every string should move into a package. A widget may have concise copy that is inappropriate for the full app. An extension can also have lifecycle actions that the app never displays. Those entries should stay in the extension catalog.
For each target-specific catalog, check three things in Xcode:
- The catalog is included in the intended target.
- The target's source code references the right table and bundle.
- Every supported locale has a reviewed value or an intentional fallback.
Avoid attaching every catalog to every target as a shortcut. That can hide ownership mistakes and package unused translations into products that do not render them. It also allows two catalogs to define the same key without making the intended winner obvious.
Use distinct key namespaces even when catalogs live in separate bundles. Prefixes such as widget., checkout., and account. make logs, screenshots, and translation reviews easier to trace. A namespace is not a substitute for the correct bundle, but it improves diagnosis when a key appears unchanged in the interface.
Separate extraction from runtime lookup
Xcode extraction finds localizable source uses and updates a catalog associated with the build context. Runtime lookup loads a value from a bundle and table. A successful runtime lookup from .main does not tell Xcode that a package should own or extract the key. The Stack Overflow report above describes this mismatch.
Build a small localization facade inside each owner. Compile the facade with the owner and have it select the owner bundle. SwiftUI views, UIKit controllers, widgets, and tests can then avoid ad hoc bundle decisions.
For a package, use the package's generated resource bundle accessor from package code. For the main app, use the app's bundle. For an extension, keep the accessor in the extension module and resolve its resources there. Do not pass .main down into a package merely because it works in the app process. The same package may be reused by an extension, a preview, or a test host with a different main bundle.
When shared code must accept an injected bundle for tests, provide a production default owned by the module:
struct LocalizedTextProvider {
let bundle: Bundle
init(bundle: Bundle = .module) {
self.bundle = bundle
}
func value(_ key: String) -> String {
String(localized: String.LocalizationValue(key), bundle: bundle)
}
}
Keep the injection internal unless consumers genuinely need to replace resources. An open bundle parameter on every call pushes ownership back onto callers and recreates the original ambiguity.
Migrate duplicated catalogs without losing coverage
Treat migration as a controlled merge, not a file move. Catalogs may contain translator comments, plural or device variations, and translation states. Apple's String Catalog guidance describes these catalog-managed details, so preserve the structured entries rather than copying only visible target strings.
Use this sequence:
- Export a list of duplicate keys from every catalog.
- Classify each key as target-owned, feature-owned, or intentionally shared.
- Select the future owner for every shared key.
- Merge translations, comments, and variations into the owner's catalog.
- Add a bundle-aware accessor in the owning module.
- Change all callers to use that accessor.
- Build and test every consuming target.
- Remove duplicate entries only after all lookups pass.
Resolve conflicting translations before deletion. If the app says "Open trip" and the widget says "View trip," the difference may be intentional. Keep separate keys when context or space constraints differ. If one locale is missing from the new owner, do not assume another target's fallback proves completion.
Keep the migration reversible. Commit the owner catalog and caller changes together, but make the change small enough to revert. Do not combine the move with broad key renaming or source-copy edits. A narrow transaction makes a missing target membership or bundle error easier to isolate.
Add cross-target validation to the workflow
Key parity inside one catalog cannot prove cross-target correctness. Validation must model ownership and consumption.
Create a manifest containing each catalog owner, supported locales, and consuming targets. Then enforce these checks in CI:
- A key has one declared owner unless duplication is explicitly approved.
- Every owner contains the default locale.
- Placeholders and variations remain structurally consistent across locales.
- Every declared catalog is present in the expected build product.
- Shared-package callers use the package accessor rather than raw
.mainlookups. - Removed keys have no remaining call sites.
Static checks catch catalog drift. Runtime tests catch bundle mistakes. Add one test target for the app, one for each extension that presents localized text, and package tests for shared resources. Tests should request representative simple, parameterized, and pluralized messages.
Add a negative test that asks for the package key from the main app bundle and confirms that this is not the supported access path. The positive test calls the package accessor and verifies a real localized value. That pair prevents a future refactor from quietly replacing .module with .main.
Test the installed products, not only previews
Previews and unit tests can use different hosts from a distributed app. Use this small device matrix:
- Default language with the main app.
- A non-default language with the main app and every extension.
- A locale with plural or grammatical variation.
- A missing optional translation to verify the intended fallback.
- A clean install after changing the device or per-app language.
- An upgrade from the previous release with existing user state.
Capture the same shared message in each surface. If the app and widget disagree, record the bundle, table, key, resolved locale, and displayed value. That evidence is more useful than a screenshot labeled "localization failed."
Also test removal. Delete a package key on a branch and confirm that the ownership validator or target test fails before release. A check that remains green cannot protect the boundary.
Common mistakes to reject in review
A global catalog included in every target looks simple but erases ownership. It encourages unrelated modules to depend on the same resource and makes safe deletion harder.
Copying common keys between catalogs creates separate review states. The values can diverge without a merge conflict because the files are independent. If the product wants one phrase, give it one owner.
Using .main from package code ties the package to whichever executable hosts it. That may pass in the app and fail in an extension or package test. Resolve resources from the module that owns them.
Moving only source-language values discards the work stored in target translations, comments, and variations. Merge the complete structured entries, then validate them.
Do not declare success because the app target builds. Build, install, and exercise each product that presents text. The failure occurs at the product boundary, so verification must cross that boundary too.
Verify iOS localization across app targets before release
Use this release checklist:
- Every localized message has one named owner.
- Shared UI text lives in a deliberate resource-bearing package.
- Package manifests declare a valid default localization.
- Catalogs are processed by the targets or packages that own them.
- Runtime accessors select the owning bundle.
- App and extension code do not bypass package accessors with raw keys.
- Duplicate keys have an approved reason or have been removed.
- Comments, placeholders, plurals, and variations survive migrations.
- CI checks ownership, catalog structure, and stale call sites.
- Installed app and extension builds pass the locale matrix.
Start with one string that currently exists in both the app and an extension. Write down its intended owner, move the complete catalog entry into that owner, route both callers through the owner's accessor, and add one cross-target test. Once that transaction passes, repeat the same process by feature instead of attempting a repository-wide catalog merge.
References
- Apple: Localizing and varying text with a String Catalog supports catalog extraction, translation state, variation, and management guidance.
- Apple: Bundling resources with a Swift package supports packaging resources with the module that owns them.
- Swift Evolution SE-0278: Package Manager localized resources defines default localization, localized resource processing, and package bundle behavior.
- Stack Overflow: Sharing String Catalog translations across Swift packages documents a concrete attempt to share keys through the main bundle and the resulting extraction and ownership question.
