Migrating Reading Plan Localization to Server-Driven JSON
Introduction
When we started building LampLit, our reading plan localization was handled entirely within the app using Xcode’s .xcstrings files. While this approach worked, it had significant limitations: every new language required an app update, translations were hardcoded into the binary, and adding new reading plans meant editing localization files alongside JSON data.
We decided to move to a server-driven localization model where translated content is downloaded from Firebase Storage. This change dramatically improved our flexibility and reduced app bundle size. In this post, I’ll walk through how we architected this solution.
The Problem with App-Based Localization
Before: Localization Keys in Code
We started by creating a separate localization file just for the reading plans to keep them separate from the application localization. Instead of giving the reading plan oject a name and summary, we created keys from them as well, and then returned computed variables for the name and summary.
// LampLit/Models/ReadingPlan.swift
struct ReadingPlan: Codable, Identifiable {
let id: Int
let nameKey: String // "1001.name"
let summaryKey: String // "1001.summary"
...
var name: String {
return NSLocalizedString(nameKey, tableName: "ReadingPlans", comment: "")
}
var summary: String {
return NSLocalizedString(summaryKey, tableName: "ReadingPlans", comment: "")
}
}
And in ReadingPlans.xcstrings:
"1001.name" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bible In A Year"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "La Biblia en un año"
}
}
}
}
Problems with This Approach
- App Updates Required: Adding a new language or updating translations requires a new app release
- Bundle Bloat: Every translation is compiled into the binary, increasing app size
- Maintenance Burden: Managing hundreds of translation keys across multiple files
- Scalability: Adding new reading plans meant updating both plan JSON and localization files
The Solution: Server-Driven Localization
We moved to storing localized content directly in JSON files downloaded from Firebase Storage, using the language code to determine which file to load.
Architecture Overview
Firebase Storage
├── reading-plans/readingPlans-en.json
├── reading-plans/readingPlans-es.json
├── reading-plans/readingPlans-fr.json
└── reading-plans/1001.json
(plan details with passages)
Implementation Details
Step 1: Update the ReadingPlan Model
The first change was to simplify the ReadingPlan model to store strings directly instead of localization keys and remove the computed properties:
// Before
struct ReadingPlan: Codable, Identifiable {
let nameKey: String
let summaryKey: String
...
var name: String {
return NSLocalizedString(nameKey, tableName: "ReadingPlans", comment: "")
}
var summary: String {
return NSLocalizedString(summaryKey, tableName: "ReadingPlans", comment: "")
}
}
// After
struct ReadingPlan: Codable, Identifiable {
let id: Int
let name: String // Direct string, not a key
let summary: String // Direct string, not a key
...
}
Step 2: Create Localized JSON Files
We created language-specific JSON files that contain the complete reading plan data with native-language strings:
readingPlans-en.json:
[
{
"name": "Bible In A Year",
"id": 1001,
"createDate": "2025-10-27",
"lastModifiedDate": "2025-10-27",
"summary": "Read through the entire Bible in one year",
"duration": 365,
"scheduleType": "daily"
},
{
"name": "Bible In A Year - Weekdays",
"id": 1002,
"createDate": "2025-10-27",
"lastModifiedDate": "2025-10-27",
"summary": "Read through the entire Bible in one year, Monday through Friday only",
"duration": 260,
"scheduleType": "weekday"
}
]
readingPlans-es.json:
[
{
"name": "La Biblia en un año",
"id": 1001,
"createDate": "2025-10-27",
"lastModifiedDate": "2025-10-27",
"summary": "Leer la Biblia completa en un año",
"duration": 365,
"scheduleType": "daily"
},
{
"name": "La Biblia en un año - Entre semana",
"id": 1002,
"createDate": "2025-10-27",
"lastModifiedDate": "2025-10-27",
"summary": "Leer la Biblia completa en un año, de lunes a viernes solamente",
"duration": 260,
"scheduleType": "weekday"
}
]
Step 3: Update ReadingPlanManager
The ReadingPlanManager now detects the device language and downloads the appropriate localized file:
// In ReadingPlanManager.swift
private let supportedLanguages = ["en", "es"]
/// Gets the current device language code, defaulting to English if unsupported
private func getLanguageCode() -> String {
let preferredLanguage = Locale.preferredLanguages.first ?? "en"
let languageCode = Locale(identifier: preferredLanguage).language.minimalIdentifier
// Return supported language or default to English
if supportedLanguages.contains(languageCode) {
return languageCode
}
return "en"
}
/// Constructs the localized filename based on device language
private func getLocalizedFileName() -> String {
let languageCode = getLanguageCode()
return "readingPlans-\(languageCode).json"
}
/// Downloads plans from Firebase, using language-specific files
private func downloadFromFirebase() async throws {
let localizedFileName = getLocalizedFileName()
let languageCode = getLanguageCode()
let storageRef = storage.reference().child("reading-plans/\(localizedFileName)")
...
// Save to language-specific cache file
let cacheFileName = "reading-plans-\(languageCode).json"
...
}
/// Loads plans from device cache, using language-specific files
private func loadFromCache() -> [ReadingPlan]? {
let languageCode = getLanguageCode()
let cacheFileName = "reading-plans-\(languageCode).json"
let cacheURL = cacheDirectory.appendingPathComponent(cacheFileName)
...
}
/// Loads plans from app bundle with fallback to English
private func loadFromBundle() -> [ReadingPlan]? {
do {
// Try localized version first
let languageCode = getLanguageCode()
let localizedFileName = "readingPlans-\(languageCode).json"
do {
let plans: [ReadingPlan] = Bundle.main.decode(localizedFileName)
return plans
} catch {
// Fall back to English if localized version not found
let plans: [ReadingPlan] = Bundle.main.decode("readingPlans-en.json")
return plans
}
} catch {
return nil
}
}
Step 4: Loading Strategy with Fallbacks
The manager uses a smart loading strategy with multiple fallback options:
private func loadPlans() async {
// 1. Try cache first (fastest)
if let cachedPlans = loadFromCache() {
self.plans = cachedPlans
return
}
// 2. Try Firebase (if network available and cache miss)
do {
try await downloadFromFirebase()
return
} catch {
...
}
// 3. Fall back to bundle (always available)
if let bundlePlans = loadFromBundle() {
self.plans = bundlePlans
return
}
// 4. Handle complete failure
throw ReadingPlanError.noPlansAvailable
}
Benefits of This Approach
1. No App Updates Required for New Languages
Adding French support is now as simple as uploading readingPlans-fr.json to Firebase:
gsutil cp readingPlans-fr.json gs://your-bucket/reading-plans/
2. Reduced Bundle Size
Localization strings are no longer compiled into the app binary, reducing download size for users.
3. Faster Iteration
Content updates can be pushed immediately without waiting for app review.
4. Better Maintenance
All data for a reading plan (name, summary, passages) is in one place, making it easier to manage.
5. Flexible for Future Features
This architecture easily extends to other content that needs localization.
Language Detection
The system uses iOS’s locale APIs to detect the user’s preferred language:
private func getLanguageCode() -> String {
let preferredLanguage = Locale.preferredLanguages.first ?? "en"
let languageCode = Locale(identifier: preferredLanguage).language.minimalIdentifier
if supportedLanguages.contains(languageCode) {
return languageCode
}
return "en" // Default fallback
}
This respects the user’s system language settings while gracefully falling back to English if their language isn’t supported.
Testing the Implementation
We verified the implementation works correctly across different scenarios:
// Test case 1: English user gets English plans
let englishVM = CurrentPlanCalendarViewModel(plan: englishPlan)
XCTAssertTrue(englishVM.plan?.name == "Bible In A Year")
// Test case 2: Spanish user gets Spanish plans
let spanishVM = CurrentPlanCalendarViewModel(plan: spanishPlan)
XCTAssertTrue(spanishVM.plan?.name == "La Biblia en un año")
// Test case 3: Unsupported language defaults to English
let frenchLocale = Locale(identifier: "fr_FR")
let languageCode = frenchLocale.language.minimalIdentifier
XCTAssertTrue(languageCode == "en") // Falls back to English
Conclusion
Moving to server-driven localization gave us flexibility we didn’t have before. We can now support new languages, update content, and manage reading plans without requiring app updates. The architecture also scales well as we add more localized content to LampLit.
The key insight is that not all content needs to live in code. By using JSON files stored in cloud storage, we separated concerns: the app handles behavior and UI, while Firebase handles content and localization.
This approach works well for any app that needs to support multiple languages with frequently updated content. Whether you’re building a reading app, news reader, or educational platform, this pattern is worth considering.