In this article
Most of the SDUI conversation happens in Android circles. RemoteCompose is AndroidX. DivKit started at Yandex for Android. Even Google I/O talks lean Android-first.
But some of the most impressive SDUI deployments run on iOS. Apple Music Classical — the app Apple built after acquiring Primephonic — uses server-driven UI at its core. HEMA, the Dutch retail chain, rebuilt their iOS app with SDUI + SwiftUI and went from multi-day feature releases to 60-minute turnarounds. Nubank serves 115 million customers with SDUI across both platforms.
iOS teams aren't behind on SDUI. They just aren't talking about it enough.
This guide covers how SDUI works with SwiftUI's declarative model, real implementation patterns, and the trade-offs iOS teams should understand before committing.
Why SDUI Makes Sense on iOS
The App Store review process takes 24-48 hours on a good day. For a critical UI fix — wrong pricing displayed, broken checkout flow, regulatory compliance update — that's an eternity.
SDUI inverts the dependency. Your app becomes a rendering engine. The server decides what appears on screen. No app update. No review. No waiting.
Here's what that looks like with SwiftUI:
Server sends:
{
"type": "screen",
"components": [
{
"type": "header",
"title": "Spring Collection",
"subtitle": "New arrivals",
"imageUrl": "https://cdn.example.com/spring-hero.jpg"
},
{
"type": "productCarousel",
"title": "Trending",
"items": [
{ "id": "p1", "name": "Classic Tee", "price": "$29", "imageUrl": "..." },
{ "id": "p2", "name": "Slim Jogger", "price": "$45", "imageUrl": "..." }
]
},
{
"type": "banner",
"text": "Free shipping on orders over $75",
"style": "accent"
}
]
}
SwiftUI renders natively:
struct ServerDrivenScreen: View {
let components: [ServerComponent]
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(components) { component in
ComponentRenderer.render(component)
}
}
}
}
}
struct ComponentRenderer {
@ViewBuilder
static func render(_ component: ServerComponent) -> some View {
switch component.type {
case .header:
HeaderView(data: component)
case .productCarousel:
ProductCarouselView(data: component)
case .banner:
BannerView(data: component)
default:
EmptyView() // Graceful fallback for unknown types
}
}
}
The user sees a native SwiftUI app. Your designers' components. Your animations. Your design system. But the arrangement — what's on screen and in what order — comes from the server.
SwiftUI's Declarative Model Is a Natural Fit
UIKit and SDUI were an awkward marriage. You'd manage view controllers, table view cells, collection view layouts — all imperatively. Q42 built HEMA's SDUI system on UICollectionView with compositional layouts, and even they admitted it was "extremely powerful but hard to grasp for new project members."
SwiftUI changes the equation. Its declarative model maps almost 1:1 to component-level SDUI:
| SDUI Concept | SwiftUI Equivalent |
|---|---|
| Component tree (JSON) | View hierarchy |
| Component type → renderer | switch on type → View |
| Layout containers | VStack, HStack, LazyVGrid |
| Conditional rendering | if let, @ViewBuilder |
| Dynamic lists | ForEach |
| Styling properties | View modifiers |
When HEMA eventually migrated from UIKit to SwiftUI for their SDUI rendering, they went from multi-day feature deliveries to building, reviewing, and shipping a new component within 60 minutes. Their product owner's reaction:
"Haha I'm confused! How did you do this so quickly?"
— HEMA Product Owner, on a 60-minute feature turnaround
That speed comes from the alignment between SwiftUI's mental model and SDUI's component architecture.
Implementation Architecture
Here's a production-ready architecture for SDUI on iOS:
1. Component Registry
The heart of your SDUI system. Maps server component types to SwiftUI views.
protocol SDUIComponent: Identifiable, Decodable {
var id: String { get }
var type: String { get }
}
class ComponentRegistry {
typealias Renderer = (AnyCodable) -> AnyView
private var renderers: [String: Renderer] = [:]
func register<V: View>(
_ type: String,
renderer: @escaping (AnyCodable) -> V
) {
renderers[type] = { data in AnyView(renderer(data)) }
}
func render(_ component: SDUIComponent) -> AnyView {
guard let renderer = renderers[component.type] else {
return AnyView(FallbackView(type: component.type))
}
return renderer(component.properties)
}
}
2. BYOC (Bring Your Own Components)
The critical insight: don't provide generic components. Let teams register their own design system components.
// Your app's setup
let registry = ComponentRegistry()
// Register your design system components
registry.register("hero") { data in
MyDesignSystem.HeroHeader(
title: data["title"].string,
imageURL: data["imageUrl"].url
)
}
registry.register("productCard") { data in
MyDesignSystem.ProductCard(
product: Product(from: data),
style: .compact
)
}
registry.register("ctaButton") { data in
MyDesignSystem.PrimaryButton(
title: data["title"].string,
action: { ActionHandler.handle(data["action"]) }
)
}
This is the difference between a generic SDUI framework and one that works with your existing app. You're not adopting someone else's button component. You're telling the SDUI system how to use your button.
3. Action System
UI without interaction is a poster. Your action system handles taps, swipes, form submissions — anything the user does.
enum SDUIAction: Decodable {
case navigate(destination: String)
case deepLink(url: URL)
case httpRequest(method: String, url: String, body: [String: Any]?)
case toast(message: String)
case haptic(style: String)
case sequence([SDUIAction])
func execute(with context: ActionContext) {
switch self {
case .navigate(let dest):
context.router.push(dest)
case .deepLink(let url):
UIApplication.shared.open(url)
case .httpRequest(let method, let url, let body):
context.network.request(method: method, url: url, body: body)
case .toast(let message):
context.toastManager.show(message)
case .haptic(let style):
HapticEngine.fire(style)
case .sequence(let actions):
actions.forEach { $0.execute(with: context) }
}
}
}
4. State Management
Server-driven doesn't mean server-controlled. Local state (form inputs, toggles, scroll position) stays on the client.
class SDUIScreenState: ObservableObject {
@Published var variables: [String: Any] = [:]
@Published var isLoading: Bool = false
@Published var components: [SDUIComponent] = []
func setVariable(_ key: String, value: Any) {
variables[key] = value
}
func resolveBinding(_ binding: String) -> Any? {
// Resolve "{{variables.email}}" to actual value
let key = binding
.replacingOccurrences(of: "{{", with: "")
.replacingOccurrences(of: "}}", with: "")
.replacingOccurrences(of: "variables.", with: "")
return variables[key]
}
}
Case Studies: iOS Teams Doing This at Scale
Primephonic → Apple Music Classical
Tom Lokhorst at Q42 built Primephonic's SDUI from scratch. The architecture was so effective that when Apple acquired Primephonic in 2021, they kept the SDUI foundation. Apple Music Classical — launched in 2023 — is built on the same server-driven architecture.
Key design decision: the JSON API was co-designed by a trio of designer, backend engineer, and app developer. Every component variation was discussed, every optional field justified. The API wasn't an afterthought — it was the product.
Their component types are small and focused: artworkRow, detailRow, carousel, sectionHeader. Each has optional fields that the server populates (or doesn't) to create variations. The client never decides what to show — it just renders what it receives.
HEMA (Q42)
HEMA's iOS app serves one of the Netherlands' largest retail chains. Their SDUI journey followed a careful pattern:
- Phase 1: UIKit + UICollectionView compositional layout for SDUI rendering
- Phase 2: Wrap SwiftUI views inside UIKit cells using
UIHostingConfiguration(iOS 16+) - Phase 3: Replace UICollectionView layouts with native SwiftUI
VStack/LazyVStack/LazyVGrid
The migration was incremental — each component could be independently converted from UIKit to SwiftUI while keeping the rest of the app unchanged. The SDUI architecture actually enabled the migration because components were already isolated.
Nubank (Flutter, but lessons apply)
Nubank's "Catalyst" system serves 115 million users. While they use Flutter (tree-walk interpreter), the architecture lessons transfer directly:
- Schema versioning: Server and client agree on a component schema version. Old clients gracefully handle unknown components.
- Fallback strategy: Unknown component types render a placeholder or are silently skipped — never crash.
- Performance monitoring: Track render time per component. SDUI shouldn't feel slower than native.
Common Pitfalls and How to Avoid Them
1. Over-abstracting the component model
Problem: Making everything server-driven. Every label, every padding value, every color.
Solution: SDUI at the component level, not the pixel level. Your server says "show a product card." Your client decides how a product card looks using your design system.
// ❌ Too granular — reimplementing SwiftUI on the server
{
"type": "vstack",
"spacing": 8,
"children": [
{ "type": "text", "value": "Product", "fontSize": 16, "fontWeight": "bold", "color": "#333" },
{ "type": "text", "value": "$29", "fontSize": 14, "color": "#666" }
]
}
// ✅ Component level — server picks component, client renders it
{
"type": "productCard",
"name": "Classic Tee",
"price": "$29",
"imageUrl": "...",
"style": "compact"
}
2. Ignoring accessibility
SDUI components must be accessible. This isn't optional.
struct ProductCardView: View {
let data: ProductCardData
var body: some View {
VStack(alignment: .leading) {
AsyncImage(url: data.imageURL)
Text(data.name).font(.headline)
Text(data.price).font(.subheadline)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(data.name), \(data.price)")
.accessibilityAddTraits(.isButton)
}
}
3. Not planning for offline
What happens when the server is unreachable? Cache the last successful response.
class SDUICache {
func cache(screen: String, components: [SDUIComponent]) {
let data = try? JSONEncoder().encode(components)
UserDefaults.standard.set(data, forKey: "sdui_cache_\(screen)")
}
func cached(screen: String) -> [SDUIComponent]? {
guard let data = UserDefaults.standard.data(forKey: "sdui_cache_\(screen)") else {
return nil
}
return try? JSONDecoder().decode([SDUIComponent].self, from: data)
}
}
4. Missing version negotiation
Your server needs to know what components the client understands. Include a schema version in every request.
// Client sends its capability version
GET /screens/home
X-SDUI-Schema-Version: 2.3
X-App-Version: 4.1.0
The server can then decide: send the fancy new animatedBanner component (v2.3+) or fall back to a basic banner (v1.0+).
When NOT to Use SDUI on iOS
SDUI isn't universal. Skip it for:
- Highly interactive UIs — Games, drawing apps, real-time collaboration. Too much local state.
- Performance-critical paths — Camera, AR, video playback. The rendering overhead isn't worth it.
- Simple static screens — Settings page with 5 items? Just build it in SwiftUI directly.
- Prototyping — SwiftUI previews are faster than setting up a server response. Build first, SDUI-ify later.
The SDUI Sweet Spot on iOS
- Content-heavy, frequently-changing screens where business teams want control without app releases
- Home screens, product listings, onboarding flows
- Promotional pages, settings/configuration screens
- Any screen where you've thought: "I wish we could change this without an app update"
Getting Started
If you're evaluating SDUI for your iOS app, here's a practical path:
- Start with one screen. Pick your home/discovery screen — it changes most frequently.
- Define 5-10 component types. Map them to your existing design system. Don't invent new components.
- Build the renderer. SwiftUI + a
ComponentRegistry+ your existing components. This is the easy part. - Design the JSON contract. Work with your backend team. Co-design, don't throw it over the wall.
- Add an action system. Navigation, deep links, analytics events at minimum.
- Handle versioning from day one. Unknown components should never crash. Fallback gracefully.
- Measure performance. SDUI should feel native. If users notice lag, you've gone too far.
The iOS SDUI Opportunity
Android has RemoteCompose. Flutter has Shorebird for code push. React Native has Expo Updates.
iOS has... nothing from Apple. No official server-driven UI framework. No code push mechanism. The App Store review process remains the bottleneck.
That's exactly why third-party SDUI solutions have more value on iOS than any other platform. The problem is sharper, the workaround is harder, and the business case is clearer.
Teams like Q42 proved it with Apple Music Classical. HEMA proved it with 60-minute feature turnarounds. Nubank proved it at 115-million-user scale.
Your iOS app is next.
Related Articles
Ready to Ship UI Without App Store Delays?
Pyramid gives you a typed DSL, native SwiftUI/Compose rendering, and built-in experimentation — so you can ship UI changes in minutes, not days. No months of infrastructure work.
Join the Waitlist →