Container App Strategies: Managing Multiple Brand Apps
Four proven strategies for structuring white-label container apps — from runtime configuration to micro-frontends.
The Container App Decision
When building white-label mobile applications, one of the most consequential architectural decisions is how to structure your container apps. The container is the shell that hosts your feature modules, applies brand theming, and delivers the final experience to users.
Choose wrong, and you'll fight your architecture for years. Choose right, and adding new brands becomes a configuration exercise.
This guide details four container app strategies, with guidance on when to use each.
Strategy 1: Single App, Runtime Configuration
How It Works
One app binary is published to app stores. At runtime, the app loads brand configuration from:
- Server-side configuration
- Deep link parameters
- MDM configuration (for enterprise)
- User selection
Implementation
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Determine brand from various sources
let brand = BrandResolver.resolve(
mdmConfig: MDMConfigReader.read(),
deepLink: launchOptions?[.url] as? URL,
storedPreference: UserDefaults.standard.string(forKey: "selectedBrand")
)
// Load brand configuration
BrandManager.shared.loadConfiguration(for: brand)
// Apply theming
ThemeManager.shared.apply(BrandManager.shared.theme)
return true
}
}
Pros
- Simplest deployment — One app to build, test, and deploy
- Instant brand switching — No app reinstall needed
- Shared app store listing — Single set of reviews and ratings
- Easy A/B testing — Test across brands without separate releases
Cons
- Larger app size — Includes all brand assets
- Security concerns — All brand configs in one binary
- Limited store customization — Same icon, screenshots for all brands
- Certification complexity — All brands certified together
Best For
- Internal enterprise apps where IT controls deployment
- B2B platforms where brands are customers, not end users
- Proof-of-concept before committing to more complex strategies
Strategy 2: Build-Time Configuration (Flavors/Schemes)
How It Works
Same codebase produces different app binaries through build configuration. Each brand gets its own app with distinct bundle ID, assets, and store listing.
iOS Implementation (Schemes + Targets)
Project/
App/
BrandA/
Info.plist
Assets.xcassets
BrandA.entitlements
BrandB/
Shared/
App source code
Configurations/
BrandA.xcconfig
BrandB.xcconfig
// BrandA.xcconfig
PRODUCT_BUNDLE_IDENTIFIER = com.company.branda
PRODUCT_NAME = Brand A App
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-BrandA
BRAND_IDENTIFIER = brandA
Android Implementation (Product Flavors)
android {
flavorDimensions "brand"
productFlavors {
brandA {
dimension "brand"
applicationId "com.company.branda"
resValue "string", "app_name", "Brand A"
buildConfigField "String", "BRAND_ID", '"brandA"'
}
brandB {
dimension "brand"
applicationId "com.company.brandb"
resValue "string", "app_name", "Brand B"
buildConfigField "String", "BRAND_ID", '"brandB"'
}
}
}
Pros
- Optimized app size — Only includes relevant brand assets
- Full store customization — Different icons, screenshots, metadata
- Clear separation — Each brand is a distinct app
- Independent releases — Can release brands on different schedules
Cons
- More complex CI/CD — N builds for N brands
- Longer build times — Multiplied by number of brands
- Configuration drift risk — Configs can diverge over time
- Testing overhead — Must test each brand variant
Best For
- Consumer apps with distinct brand identities
- Apps with different feature sets per brand
- Brands with independent release cycles
Strategy 3: Dynamic Framework Loading
How It Works
Container app loads feature frameworks dynamically at runtime based on brand configuration. Features can be updated without full app releases.
Implementation Concept
class FrameworkLoader {
func loadFeature(_ featureId: String) async throws -> FeatureProtocol {
// Check if framework is cached
if let cached = FrameworkCache.shared.get(featureId) {
return cached
}
// Download framework bundle
let bundle = try await FrameworkDownloader.download(featureId)
// Load and instantiate
guard let principalClass = bundle.principalClass as? FeatureProtocol.Type else {
throw FrameworkError.invalidBundle
}
let feature = principalClass.init()
FrameworkCache.shared.store(feature, for: featureId)
return feature
}
}
Platform Considerations
iOS Limitations:
- App Store apps cannot download and execute code
- Enterprise distribution can use this pattern
- Swift Packages/frameworks must be bundled at build time for App Store
Android:
- Dynamic feature modules via Play Feature Delivery
- On-demand and conditional delivery supported
// Android Dynamic Feature Module
class FeatureInstaller(private val context: Context) {
private val splitInstallManager = SplitInstallManagerFactory.create(context)
suspend fun installFeature(moduleName: String): InstallState {
val request = SplitInstallRequest.newBuilder()
.addModule(moduleName)
.build()
return splitInstallManager.startInstall(request).await()
}
}
Pros
- Update features independently — No full app release needed
- Reduce initial download size — Load features on demand
- Maximum flexibility — Different feature sets per user/brand
- A/B testing at feature level — Test individual features
Cons
- Platform restrictions — iOS App Store limits dynamic loading
- Complexity — More moving parts to manage
- Network dependency — Features may fail to load
- Debugging difficulty — Dynamic loading is harder to trace
Best For
- Enterprise apps with own distribution
- Android apps with modular features
- Apps with optional/premium features
Strategy 4: Micro-Frontend Architecture
How It Works
Features are delivered as independent mini-apps, each with its own technology stack and deployment pipeline. A shell app composes them into a unified experience.
Implementation Patterns
1. WebView-Based Micro-Frontends
class MicroFrontendHost: UIViewController {
private let webView = WKWebView()
func loadMicroFrontend(url: URL) {
webView.load(URLRequest(url: url))
}
// Bridge for native ↔ web communication
func setupBridge() {
let bridge = WebBridge()
webView.configuration.userContentController
.add(bridge, name: "nativeBridge")
}
}
2. React Native Micro-Frontends
class ReactMicroFrontend {
func loadComponent(named: String, props: [String: Any]) -> UIView {
let bridge = RCTBridge(delegate: self, launchOptions: nil)
let rootView = RCTRootView(
bridge: bridge!,
moduleName: named,
initialProperties: props
)
return rootView
}
}
Communication Between Micro-Frontends
// Event bus for cross-frontend communication
protocol MicroFrontendEventBus {
func publish(_ event: MicroFrontendEvent)
func subscribe(to eventType: String, handler: @escaping (MicroFrontendEvent) -> Void)
}
struct MicroFrontendEvent {
let type: String
let source: String
let payload: [String: Any]
}
Pros
- Team autonomy — Teams own their features end-to-end
- Technology flexibility — Different stacks for different features
- Independent deployment — Update features without coordinating
- Scaling — Add teams without increasing coordination
Cons
- Consistency challenges — Harder to maintain unified UX
- Performance overhead — Multiple runtimes/frameworks
- Complexity — Significant infrastructure investment
- Communication overhead — Cross-frontend data sharing
Best For
- Large organizations with many autonomous teams
- Acquisitions — Integrating apps built on different stacks
- Gradual migrations — Moving from legacy to modern stack
Decision Matrix
| Factor | Runtime Config | Build-Time | Dynamic Loading | Micro-Frontend |
|---|---|---|---|---|
| Team size | Small | Small-Medium | Medium | Large |
| Release frequency | High | Medium | High | High |
| Brand differences | Low | Medium | Medium | High |
| Store customization | No | Yes | Yes | Yes |
| App size | Larger | Optimal | Variable | Variable |
| Complexity | Low | Medium | High | Very High |
| Setup time | Fast | Medium | Slow | Very Slow |
Migration Path
Most teams evolve through strategies:
- Start with Runtime Configuration — Fastest to implement
- Move to Build-Time when store customization matters
- Add Dynamic Loading for specific features (Android) or enterprise (iOS)
- Consider Micro-Frontends only when team scale demands it
Conclusion
The right container strategy depends on your specific context:
- Small team, quick start → Runtime Configuration
- Consumer brands, store presence → Build-Time Configuration
- Enterprise, modular features → Dynamic Framework Loading
- Large org, team autonomy → Micro-Frontend Architecture
Choose the simplest approach that meets your current needs, but architect for migration to more sophisticated patterns as you grow.
Container strategies refined through multiple enterprise white-label implementations serving millions of users.
