White Label iOS Apps: Why Multiple Targets Beat Runtime Configuration

How we scaled from 1 app to 12 client apps without losing our sanity

After building white label iOS apps for years, I've seen both approaches: runtime configuration (single target, client determined at startup) and multiple build targets (separate target per client). I've made the mistakes, fought the bugs, and learned the hard way. Here's why multiple targets is the only sustainable approach for production white label apps.

The Tempting Trap: Runtime Configuration

It sounds so elegant:

// Single app, runtime config - seems simple!
struct AppConfig {
    static func load() -> Config {
        // Determine client from API or config file
        let response = await API.fetchClientConfig()
        return Config(
            brandName: response.brandName,
            primaryColor: response.primaryColor,
            apiEndpoint: response.apiEndpoint
        )
    }
}

The pitch developers give:

I believed this. I was wrong.

Why Runtime Configuration Fails at Scale

Problem 1: App Store Submission Nightmare

Reality check:

With runtime config:

You end up with hacks:

// This is a nightmare to maintain
if bundleIdentifier == "com.company.client-a" {
    brandName = "HealthTrack"
    primaryColor = .blue
} else if bundleIdentifier == "com.company.client-b" {
    brandName = "MedCare"
    primaryColor = .green
} else {
    // 12 more else-if blocks...
}

Problem 2: Security and API Keys

Each client needs:

Runtime config means:

// ⚠️ SECURITY DISASTER
struct APIKeys {
    let firebase = [
        "client-a": "AIzaSyA...",  // All keys exposed
        "client-b": "AIzaSyB...",  // in every binary
        "client-c": "AIzaSyC...",  // Client C can see A's keys!
    ]
    
    let stripe = [
        "client-a": "pk_live_abc",  // This is terrible
        "client-b": "pk_live_def",  // Don't do this
        "client-c": "pk_live_ghi"
    ]
}

Every client's sensitive keys are in every binary. Security audit nightmare.

Problem 3: Client-Specific Features

// This code grows until it's unmaintainable
class FeatureManager {
    func isFeatureEnabled(_ feature: Feature) -> Bool {
        switch (feature, currentClient) {
        case (.darkMode, "client-a"): return true
        case (.darkMode, "client-b"): return false
        case (.biometric, "client-a"): return true
        case (.biometric, "client-b"): return true
        case (.biometric, "client-c"): return false
        // 200 more cases...
        default: return false
        }
    }
}

The Better Way: Multiple Build Targets

Architecture Overview

WhiteLabelApp/
├── Shared/              # Shared business logic
│   ├── Core/
│   ├── Services/
│   ├── Models/
│   └── ViewModels/
├── Clients/
│   ├── ClientA/
│   │   ├── Config/
│   │   │   ├── ClientA-Info.plist
│   │   │   ├── GoogleService-Info.plist
│   │   │   └── Config.swift
│   │   ├── Resources/
│   │   │   ├── Assets.xcassets
│   │   │   ├── LaunchScreen.storyboard
│   │   │   └── Localizable.strings
│   │   └── Features/   # Client-specific features
│   ├── ClientB/
│   │   └── [same structure]
│   └── ClientC/
│       └── [same structure]
└── WhiteLabelApp.xcodeproj

Target Configuration

// Clients/ClientA/Config/Config.swift
// This file is ONLY included in ClientA target

import UIKit

enum AppConfig {
    static let bundleIdentifier = "com.company.clienta"
    static let appName = "HealthTrack"
    
    // API Configuration
    static let apiBaseURL = URL(string: "https://api-a.example.com")!
    static let apiKey = "SECRET_KEY_ONLY_IN_CLIENT_A_BINARY"
    
    // Branding
    static let primaryColor = UIColor(hex: "#007AFF")
    static let secondaryColor = UIColor(hex: "#5AC8FA")
    static let appIcon = "AppIcon"  // References ClientA's asset catalog
    
    // Features - compile-time constants
    static let hasDarkModeSupport = true
    static let hasBiometricAuth = true
    static let supportedPaymentMethods: [PaymentMethod] = [.stripe, .paypal]
    
    // Firebase
    static let firebaseOptions: FirebaseOptions = {
        let options = FirebaseOptions(
            googleAppID: "1:123:ios:abc",
            gcmSenderID: "123"
        )
        options.apiKey = "AIzaSyA..."  // Only ClientA's key
        options.projectID = "clienta-prod"
        return options
    }()
}

Benefits of Multiple Targets

1. Compile-Time Safety

// With targets, this is a compile-time constant
#if CLIENTA
    let supportsDarkMode = true
    let apiEndpoint = "https://api-a.example.com"
#elseif CLIENTB
    let supportsDarkMode = false
    let apiEndpoint = "https://api-b.example.com"
#endif

// Unused code is removed by compiler
// Smaller binary, better performance

2. Security Isolation

// Each binary only contains its own keys
// ClientA binary:
let apiKey = "CLIENT_A_KEY_ONLY"  // ClientB can't see this

// ClientB binary:
let apiKey = "CLIENT_B_KEY_ONLY"  // ClientA can't see this

3. Easy Testing

// Test each target independently
class ClientACheckoutTests: XCTestCase {
    func testCheckout() {
        // Only tests ClientA configuration
        // Fast, focused, reliable
        XCTAssertEqual(AppConfig.appName, "HealthTrack")
    }
}

// Run specific target tests
// xcodebuild test -scheme ClientA

4. Independent Deployment

# Fastlane - Deploy specific client
lane :deploy_client_a do
  build_app(scheme: "ClientA")
  upload_to_testflight(
    app_identifier: "com.company.clienta",
    skip_waiting_for_build_processing: false
  )
end

lane :deploy_client_b do
  build_app(scheme: "ClientB")
  upload_to_testflight(
    app_identifier: "com.company.clientb",
    skip_waiting_for_build_processing: false
  )
end

# Deploy all clients
lane :deploy_all do
  ["ClientA", "ClientB", "ClientC"].each do |client|
    build_app(scheme: client)
    upload_to_testflight(
      app_identifier: bundle_id_for_client(client)
    )
  end
end

Implementation Guide

Step 1: Set Up Project Structure

Create a script to set up new client targets:

#!/bin/bash
# scripts/create_client.sh

CLIENT_NAME=$1

if [ -z "$CLIENT_NAME" ]; then
    echo "Usage: ./create_client.sh ClientName"
    exit 1
fi

echo "Creating new client: $CLIENT_NAME"

# Create directories
mkdir -p "Clients/$CLIENT_NAME/Config"
mkdir -p "Clients/$CLIENT_NAME/Resources"
mkdir -p "Clients/$CLIENT_NAME/Features"

# Copy template files
cp "Templates/Config.swift" "Clients/$CLIENT_NAME/Config/"
cp "Templates/Info.plist" "Clients/$CLIENT_NAME/Config/$CLIENT_NAME-Info.plist"
cp -r "Templates/Assets.xcassets" "Clients/$CLIENT_NAME/Resources/"

echo "✅ Created client structure"

Step 2: Shared Code Architecture

// Shared/Core/ConfigProtocol.swift
// Define what every client must implement

protocol ClientConfiguration {
    // Required configuration
    static var bundleIdentifier: String { get }
    static var appName: String { get }
    static var apiBaseURL: URL { get }
    
    // Branding
    static var primaryColor: UIColor { get }
    static var secondaryColor: UIColor { get }
    static var fontFamily: String { get }
    
    // Features
    static var supportedFeatures: Set { get }
    static var paymentMethods: [PaymentMethod] { get }
    
    // Third-party services
    static var firebaseOptions: FirebaseOptions { get }
    static var analyticsKey: String { get }
}

enum Feature: String, CaseIterable {
    case darkMode
    case biometricAuth
    case socialLogin
    case pushNotifications
    case premiumTier
}

// Use in shared code
class FeatureManager {
    static func isEnabled(_ feature: Feature) -> Bool {
        AppConfig.supportedFeatures.contains(feature)
    }
}

// Clean, type-safe, no runtime switches
if FeatureManager.isEnabled(.darkMode) {
    // Enable dark mode UI
}

Handling Common Challenges

Challenge 1: Shared Code Updates

Problem: Update affects all clients

Solution: Feature flags + gradual rollout

// Shared/Core/FeatureFlags.swift
enum FeatureFlags {
    static var newCheckoutFlow: Bool {
        #if CLIENTA
        return true  // ClientA ready
        #elseif CLIENTB
        return false  // ClientB testing
        #else
        return false  // Others not yet
        #endif
    }
}

// Shared code uses flags
if FeatureFlags.newCheckoutFlow {
    NewCheckoutView()
} else {
    LegacyCheckoutView()
}

Challenge 2: Different API Versions

Problem: Some clients on API v1, others on v2

// Shared/Services/APIService.swift
protocol APIServiceProtocol {
    func fetchUser(id: String) async throws -> User
}

// Shared/Services/APIServiceV1.swift
class APIServiceV1: APIServiceProtocol {
    func fetchUser(id: String) async throws -> User {
        // v1 implementation
    }
}

// Shared/Services/APIServiceV2.swift
class APIServiceV2: APIServiceProtocol {
    func fetchUser(id: String) async throws -> User {
        // v2 implementation
    }
}

// Client config determines version
extension AppConfig {
    static var apiService: APIServiceProtocol {
        #if CLIENTA || CLIENTB
        return APIServiceV2()  // New clients
        #else
        return APIServiceV1()  // Legacy clients
        #endif
    }
}

Cost-Benefit Analysis

Multiple Targets Approach

Costs:

Benefits:

Break-even: 3+ clients, the benefits far outweigh costs

Runtime Configuration Approach

Costs:

Benefits:

Real-World Example

Before (Runtime Config):

After (Multiple Targets):

Conclusion

Runtime configuration seems simpler at first, but becomes a maintenance nightmare.

Multiple targets require upfront investment, but pay dividends forever.

After managing both approaches in production, my recommendation is clear:

Use multiple build targets for white label apps.

The compile-time safety, security isolation, and independent deployment make it the only sustainable approach for production white label iOS applications.

Your future self (and your security team) will thank you.