Localization and JSON

Mark
Posted: Jan 5th, 2026
By: Mark

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

  1. App Updates Required: Adding a new language or updating translations requires a new app release
  2. Bundle Bloat: Every translation is compiled into the binary, increasing app size
  3. Maintenance Burden: Managing hundreds of translation keys across multiple files
  4. 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.

Tags:
json lamplit