Our portfolio includes 27 iOS and macOS apps. Each one supports between 15 and 38 languages. That means thousands of localized strings across exam prep apps like Einbürgerungstest and CSCS Quiz, productivity tools like Phygital Timer, and utilities like Email Converter.
This post covers the localization workflow that makes this manageable: String Catalogs, runtime language switching, the Bundle override pattern, and the specific gotchas that cost us days to figure out.
Why 38 Languages?
Apple supports roughly 40 App Store locales. Each locale you add means your app appears in that language's App Store search results. For a quiz app about German citizenship, you might think German and English are enough. But Turkish, Arabic, Russian, and Polish speakers are among the largest immigrant groups taking the Einbürgerungstest. If your app only shows English in those markets, a competitor with Turkish metadata will outrank you.
Localization is not just translation. It is distribution. Every locale you skip is a market where your app is invisible.
String Catalogs (.xcstrings)
Before Xcode 15, iOS localization meant managing .strings and .stringsdict files — one file per language, with no compile-time validation. Typos in keys were silent failures. Missing translations were invisible until a QA tester happened to switch their device language.
String Catalogs (Localizable.xcstrings) changed this. A single JSON-backed file holds all languages, all keys, and all translation states. Xcode shows you completion percentage per language. You can see at a glance that your French translation is 94% complete and your Korean is at 78%.
The migration is straightforward. Xcode offers a one-click "Migrate to String Catalog" option for existing .strings files. The result is a single .xcstrings file that replaces all per-language .lproj directories for that table.
For our portfolio, this was a multiplier. Instead of auditing 38 separate files per app, we audit one file with 38 columns. Export/import for translators works through Xcode's Editor menu — Export Localizations produces an .xcloc bundle that any professional translator can work with.
The Key Naming Convention
With hundreds of strings per app, naming conventions matter. We use a structured format:
{feature}_{component}_{property}
// Examples:
onboarding_feature_prices
settings_language_picker_title
chart_tooltip_price_range
paywall_button_restore
quiz_result_score_label
This keeps strings grouped logically in Xcode's String Catalog editor and makes it obvious which feature a string belongs to when reviewing translations. Generic keys like button_ok or title become ambiguous fast when you have 200+ strings.
Runtime Language Switching
Many of our apps let users choose their language inside the app, independent of the device language. A Turkish speaker in Germany might want their phone in Turkish but the Einbürgerungstest app in German, because the exam is in German.
This is where most developers hit a wall. The intuitive approach — setting .environment(\.locale) in SwiftUI — does not work the way you expect.
The problem: .environment(\.locale) affects SwiftUI's built-in localized views (like Text("key")), but it does not affect String(localized:) calls. That function reads from Bundle.main directly, ignoring the SwiftUI environment entirely.
If your app uses String(localized:) anywhere — in view models, in computed properties, in helper functions — the environment approach silently fails. Half your UI switches language, half stays in the device language.
The Bundle Override Pattern
The solution is to override how Bundle.main resolves localized strings at runtime. This is a well-known pattern in UIKit, adapted here for SwiftUI with @Observable:
@MainActor @Observable
class LocalizationManager {
var currentLanguage: String = Locale.current
.language.languageCode?.identifier ?? "en"
var refreshID = UUID()
func setLanguage(_ code: String) {
currentLanguage = code
Bundle.setLanguage(code)
UserDefaults.standard.set(
[code], forKey: "AppleLanguages"
)
refreshID = UUID()
}
}
The Bundle.setLanguage() method swizzles Bundle.main to load strings from the selected language's .lproj bundle:
extension Bundle {
static func setLanguage(_ code: String) {
object_setClass(Bundle.main, PrivateBundle.self)
}
class PrivateBundle: Bundle {
override func localizedString(
forKey key: String,
value: String?,
table tableName: String?
) -> String {
guard let langCode = UserDefaults.standard
.stringArray(forKey: "AppleLanguages")?.first,
let path = Bundle.main.path(
forResource: langCode, ofType: "lproj"),
let bundle = Bundle(path: path)
else {
return super.localizedString(
forKey: key, value: value, table: tableName
)
}
return bundle.localizedString(
forKey: key, value: value, table: tableName
)
}
}
}
The critical piece is the refreshID. In the app's root scene, you attach it as a view identity:
@main
struct MyApp: App {
@State private var localizationManager =
LocalizationManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(localizationManager)
.id(localizationManager.refreshID)
}
}
}
When refreshID changes, SwiftUI destroys and recreates the entire view hierarchy, forcing every String(localized:) call to re-evaluate against the new bundle. Without this, the view tree caches stale strings.
The Enum Localization Gotcha
This one cost us a full day of debugging. SwiftUI caches the result of computed properties on enums. If you localize enum display names via a computed property, the cached value persists even after a language switch:
// This BREAKS on language switch:
enum Country: String {
case italy, france
var displayName: String {
String(localized: "country_\(rawValue)")
}
}
After switching from English to German, Country.italy.displayName still returns "Italy" instead of "Italien". SwiftUI evaluated the property once and cached the result.
The fix is to use a function instead of a property:
// This WORKS on language switch:
enum Country: String {
case italy, france
func displayName() -> String {
String(localized: "country_\(rawValue)")
}
}
Functions are called on every render. Properties can be cached. This distinction matters nowhere else in SwiftUI as much as it does in localization.
An alternative is to resolve the string in the view layer entirely:
struct CountryRow: View {
let country: Country
var body: some View {
Text(String(localized: "country_\(country.rawValue)"))
}
}
The Stats
Localization Auditing at Scale
With 27 apps and 38 languages each, missing translations are inevitable. Our shared Swift Package (AppFoundation) contains paywall strings, onboarding copy, and error messages. When AppFoundation adds a new string key, every app that uses that component needs the corresponding translation in its own .xcstrings file.
There is no automatic inheritance. String Catalogs live per-target, not per-package. So after every AppFoundation update, we run a batch audit: a script that checks each app's .xcstrings for missing keys and flags gaps before submission.
Without this, apps silently fall back to English for missing keys. On a paywall screen, an English "Subscribe" button in an otherwise-Turkish UI tanks conversion rates. Apple reviewers in localized markets also notice and flag it.
App Store Metadata Localization
In-app localization is only half the job. App Store metadata — title, subtitle, description, keywords — needs localization too. This determines whether your app appears in local search results.
For our quiz app family, metadata localization is the difference between appearing for "Einbürgerungstest Übung" and being invisible to German-speaking searchers. Each of the 38 App Store locales gets its own keyword set, description, and promotional text.
A common mistake: keyword contamination. Before any metadata push, we verify that every keyword actually matches the locale's language. It is easy to accidentally leave German keywords in the English locale (like "strompreis" in en-US) or English keywords in the German locale. Each misplaced keyword wastes characters from the 100-character limit and does nothing for discoverability.
What Does Not Work
.environment(\.locale) alone. As described above, it only affects SwiftUI's Text views that use string literal initialization. Any String(localized:) call is unaffected. For apps that mix both patterns, this creates a partial-switch state that is worse than not switching at all.
Legacy .strings files at scale. Managing 38 separate .strings files per app is unsustainable. One missing semicolon in one file crashes the build. String Catalogs eliminate this entire class of errors.
Machine translation without review. Machine-translated paywall copy reads awkwardly and reduces purchase confidence. We use machine translation as a first pass, then review high-value screens (paywall, onboarding, error messages) with native speakers. Low-stakes strings (settings labels, tooltips) go out machine-translated and get refined based on user feedback.
Key Takeaways
- String Catalogs replace all legacy .strings files with a single, auditable JSON-backed file per target. Migrate now if you have not already.
.environment(\.locale)does not affectString(localized:)— use the Bundle override pattern for runtime language switching.- Use functions, not computed properties, for localized enum display names. SwiftUI caches property results across language switches.
- Audit after every shared package update. New string keys do not propagate automatically to consuming apps.
- Localization is distribution. Every App Store locale you support is a market where your app can appear in search results.