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:
- "One codebase to maintain!"
- "Deploy once, works for all clients!"
- "Just update a config file!"
- "So much easier!"
I believed this. I was wrong.
Why Runtime Configuration Fails at Scale
Problem 1: App Store Submission Nightmare
Reality check:
- Client A wants: Blue theme, "HealthTrack" name
- Client B wants: Green theme, "MedCare" name
- Client C wants: Red theme, "WellnessApp" name
With runtime config:
- App Store name: ??? (can't change at runtime)
- Bundle identifier: ??? (must be unique)
- App icon: ??? (can't swap at runtime)
- Launch screen: ??? (baked into binary)
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:
- Different API endpoints
- Different Firebase projects
- Different push notification certificates
- Different analytics keys
- Different crash reporting
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:
- Initial setup: 2-3 days per client
- More complex Xcode project
- Need automation scripts
- Slightly larger repo
Benefits:
- Security: Keys isolated per client
- Performance: Dead code eliminated by compiler
- Maintenance: Changes don't affect all clients
- Testing: Faster, focused tests
- Deployment: Independent releases
- Debugging: Easier to reproduce client-specific issues
Break-even: 3+ clients, the benefits far outweigh costs
Runtime Configuration Approach
Costs:
- Growing if-else complexity
- All keys in every binary (security risk)
- Slower build times (all code always compiled)
- Harder testing
- Deployment coordination required
Benefits:
- Initial development faster (first month)
- Single binary to debug
- ...that's about it
Real-World Example
Before (Runtime Config):
- Lines of Code: 250,000
- Configuration LOC: 5,000 (2%)
- Build Time: 8 minutes
- Test Time: 25 minutes
- Deploy Time: 15 minutes (all clients at once)
- Security: All keys in every binary ❌
- Bugs: 1-2 config-related bugs per sprint
After (Multiple Targets):
- Lines of Code: 220,000 (30K less from dead code elimination)
- Configuration LOC: 800 per client (cleaner)
- Build Time: 6 minutes (parallel builds)
- Test Time: 8 minutes per client (parallelized)
- Deploy Time: 5 minutes per client (independent)
- Security: Keys isolated per client ✅
- Bugs: 0 config-related bugs in 6 months
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.