Server-Driven UI for iOS: Building Dynamic SwiftUI Screens

Published: March 27, 2026 18 min read Intermediate

Your Android team just shipped a UI update in 30 minutes using server-driven UI with Jetpack Compose. Meanwhile, your iOS build is sitting in App Store review — day two. Sound familiar?

Server-Driven UI (SDUI) gives iOS teams the same superpower Android developers have been leveraging: update your app's interface without submitting a new binary to the App Store. In this tutorial, we'll build a production-quality SDUI system from scratch using SwiftUI — with real, compilable Swift code you can drop into a project today.

If you're new to the concept, start with our complete guide to server-driven UI. Already know the fundamentals? Let's build.

Table of Contents

  1. Why iOS Teams Need SDUI
  2. The iOS SDUI Landscape
  3. Architecture Overview
  4. Step 1: Define the Component Model
  5. Step 2: Build the Component Registry
  6. Step 3: Create the Rendering Engine
  7. Step 4: Wire Up Actions & Navigation
  8. Step 5: Manage State
  9. Complete Working Example
  10. Challenges Specific to iOS
  11. Why Teams Choose Pyramid for iOS

Why iOS Teams Need SDUI

Apple's App Store review process is the elephant in the room. Even with improvements in review speed, iOS teams face unique friction that Android teams don't:

The business case for SDUI is straightforward: teams using server-driven UI ship UI changes 10-50x faster than traditional native release cycles. For iOS specifically, that delta is even larger because of the App Store bottleneck.

Apple's Guidelines: SDUI is fully compliant with App Store Review Guidelines. You're not downloading executable code — you're fetching data that your pre-compiled app interprets. This is the same pattern used by every app that renders content from an API. Apple themselves use SDUI in the App Store app.

The iOS SDUI Landscape

Before building from scratch, let's survey what's available. The iOS SDUI ecosystem is less mature than Android's, but it's growing fast. Here's the current landscape as covered in our 2026 SDUI framework comparison:

Custom JSON → SwiftUI (DIY)

The most common approach. Define a JSON schema, write a Swift decoder, map types to SwiftUI views. It's what we'll build in this tutorial. Full control, but you own every line of infrastructure. Read about the problems with raw JSON approaches before committing to this long-term.

Yandex DivKit

Open-source from Yandex, supports iOS natively. Heavy XML-like DSL, large runtime. Good for content-heavy apps, less ideal for complex interactions. The learning curve is steep and the community outside Russia is small.

Judo

Was a promising SwiftUI-native SDUI tool with a visual editor. However, Judo shut down their enterprise offering in 2025, making it risky for production use. The open-source remnants exist but lack active maintenance.

Pyramid iOS SDK

Full-featured SDUI platform with a typed DSL, BYOC (Bring Your Own Components) compiler, and shared backend between iOS and Android. We'll discuss why teams choose Pyramid at the end — but first, let's understand the fundamentals by building one ourselves.

Not sure if you should build or buy? Use our SDUI build vs. buy calculator to estimate the true cost of each approach.

Architecture Overview

An SDUI system on iOS follows the same architecture patterns as on any platform. Four pieces work together:

  1. Server: Sends a JSON (or typed DSL) component tree describing the screen
  2. Component Model: Swift types (Codable enums) representing every possible UI element
  3. Registry: Maps component types to SwiftUI View implementations
  4. Renderer: Walks the component tree recursively, instantiating the matching SwiftUI view for each node

State management slots in via @StateObject, @ObservedObject, and SwiftUI's @Environment. The server can define initial state, and user interactions (taps, text input) update a centralized state store that the renderer observes.

Key Insight: Think of it like HTML rendering. Safari knows how to draw <div>, <button>, <img>. The server sends a document tree. Your SDUI app works identically — pre-built native views, server-defined arrangement. Same principle, UIKit/SwiftUI performance.

Let's build each piece. All code targets Swift 5.9+ and iOS 16+ (we'll use NavigationStack over the deprecated NavigationView).

Step 1: Define the Component Model

The component model is the contract between your server and your iOS client. Every component the server can send must have a corresponding Swift type. We use an enum with associated values for type safety and exhaustive switch matching.

ServerComponent.swift
import Foundation

// MARK: - Component Tree

enum ServerComponent: Codable, Identifiable {
    case column(ColumnData)
    case row(RowData)
    case text(TextData)
    case image(ImageData)
    case button(ButtonData)
    case textField(TextFieldData)
    case spacer(SpacerData)
    case card(CardData)
    case scrollView(ScrollViewData)
    case lazyVStack(LazyVStackData)
    
    var id: String {
        switch self {
        case .column(let d): return d.id
        case .row(let d): return d.id
        case .text(let d): return d.id
        case .image(let d): return d.id
        case .button(let d): return d.id
        case .textField(let d): return d.id
        case .spacer(let d): return d.id
        case .card(let d): return d.id
        case .scrollView(let d): return d.id
        case .lazyVStack(let d): return d.id
        }
    }
}

Data Structs for Each Component

Each component case has an associated data struct. This keeps the enum clean and gives us natural Codable conformance:

ComponentData.swift
// MARK: - Layout Components

struct ColumnData: Codable {
    let id: String
    let children: [ServerComponent]
    var spacing: CGFloat = 8
    var padding: CGFloat = 0
    var alignment: HorizontalAlignmentType = .center
}

struct RowData: Codable {
    let id: String
    let children: [ServerComponent]
    var spacing: CGFloat = 8
    var alignment: VerticalAlignmentType = .center
}

struct ScrollViewData: Codable {
    let id: String
    let children: [ServerComponent]
    var axis: ScrollAxisType = .vertical
}

struct LazyVStackData: Codable {
    let id: String
    let children: [ServerComponent]
    var spacing: CGFloat = 8
}

// MARK: - Content Components

struct TextData: Codable {
    let id: String
    let content: String
    var style: TextStyleType = .body
    var color: String? = nil
}

struct ImageData: Codable {
    let id: String
    let url: String
    var contentMode: ContentModeType = .fit
    var width: CGFloat? = nil
    var height: CGFloat? = nil
    var cornerRadius: CGFloat = 0
    var accessibilityLabel: String? = nil
}

struct SpacerData: Codable {
    let id: String
    var height: CGFloat? = nil
    var width: CGFloat? = nil
}

// MARK: - Interactive Components

struct ButtonData: Codable {
    let id: String
    let label: String
    let action: ServerAction
    var style: ButtonStyleType = .primary
    var isEnabled: Bool = true
}

struct TextFieldData: Codable {
    let id: String
    let placeholder: String
    let stateKey: String
    var keyboardType: KeyboardStyleType = .default
    var isSecure: Bool = false
}

struct CardData: Codable {
    let id: String
    let children: [ServerComponent]
    var padding: CGFloat = 16
    var cornerRadius: CGFloat = 12
    var action: ServerAction? = nil
}

Supporting Enums

Define the enums that components reference. These are all Codable with String raw values for clean JSON mapping:

ComponentEnums.swift
enum TextStyleType: String, Codable {
    case largeTitle, title, headline, subheadline, body, caption, footnote
}

enum ButtonStyleType: String, Codable {
    case primary, secondary, destructive, plain
}

enum HorizontalAlignmentType: String, Codable {
    case leading, center, trailing
}

enum VerticalAlignmentType: String, Codable {
    case top, center, bottom
}

enum ContentModeType: String, Codable {
    case fit, fill
}

enum ScrollAxisType: String, Codable {
    case vertical, horizontal
}

enum KeyboardStyleType: String, Codable {
    case `default`, email = "email", number = "number", url = "url"
}

Defining Actions

Actions describe what happens when users interact with components. Just like the Compose tutorial's sealed class approach, we use an enum:

ServerAction.swift
enum ServerAction: Codable {
    case navigate(NavigateAction)
    case apiCall(ApiCallAction)
    case updateState(UpdateStateAction)
    case showAlert(ShowAlertAction)
    case openURL(OpenURLAction)
    case sequence(SequenceAction)
}

struct NavigateAction: Codable {
    let destination: String
    var params: [String: String] = [:]
    var presentation: PresentationType = .push
}

enum PresentationType: String, Codable {
    case push, sheet, fullScreenCover
}

struct ApiCallAction: Codable {
    let endpoint: String
    var method: String = "POST"
    var bodyFromState: [String] = []
}

struct UpdateStateAction: Codable {
    let key: String
    let value: AnyCodable
}

struct ShowAlertAction: Codable {
    let title: String
    var message: String? = nil
}

struct OpenURLAction: Codable {
    let url: String
}

struct SequenceAction: Codable {
    let actions: [ServerAction]
}

Custom Codable Conformance

The ServerComponent enum needs a custom Codable implementation to handle the "type" discriminator in JSON. This is the trickiest part — get it right once, and adding new components is trivial:

ServerComponent+Codable.swift
extension ServerComponent {
    private enum CodingKeys: String, CodingKey {
        case type
    }
    
    private enum ComponentType: String, Codable {
        case column, row, text, image, button
        case textField, spacer, card, scrollView, lazyVStack
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let type = try container.decode(ComponentType.self, forKey: .type)
        
        let singleContainer = try decoder.singleValueContainer()
        
        switch type {
        case .column:
            self = .column(try singleContainer.decode(ColumnData.self))
        case .row:
            self = .row(try singleContainer.decode(RowData.self))
        case .text:
            self = .text(try singleContainer.decode(TextData.self))
        case .image:
            self = .image(try singleContainer.decode(ImageData.self))
        case .button:
            self = .button(try singleContainer.decode(ButtonData.self))
        case .textField:
            self = .textField(try singleContainer.decode(TextFieldData.self))
        case .spacer:
            self = .spacer(try singleContainer.decode(SpacerData.self))
        case .card:
            self = .card(try singleContainer.decode(CardData.self))
        case .scrollView:
            self = .scrollView(try singleContainer.decode(ScrollViewData.self))
        case .lazyVStack:
            self = .lazyVStack(try singleContainer.decode(LazyVStackData.self))
        }
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        
        switch self {
        case .column(let d):
            try container.encode(ComponentType.column, forKey: .type)
            try d.encode(to: encoder)
        case .text(let d):
            try container.encode(ComponentType.text, forKey: .type)
            try d.encode(to: encoder)
        // ... repeat for each case
        default: break
        }
    }
}

Step 2: Build the Component Registry

The registry connects your ServerComponent types to SwiftUI views. In Swift, we use @ViewBuilder and type erasure with AnyView to make this work:

ComponentRegistry.swift
import SwiftUI

@MainActor
final class ComponentRegistry: ObservableObject {
    
    typealias ViewFactory = (
        ServerComponent,
        SDUIStateManager,
        ActionHandler
    ) -> AnyView
    
    private var factories: [String: ViewFactory] = [:]
    
    init() {
        registerDefaults()
    }
    
    // MARK: - Registration
    
    func register<V: View>(
        _ componentType: String,
        @ViewBuilder factory: @escaping (
            ServerComponent,
            SDUIStateManager,
            ActionHandler
        ) -> V
    ) {
        factories[componentType] = { component, state, handler in
            AnyView(factory(component, state, handler))
        }
    }
    
    // MARK: - Resolution
    
    func resolve(
        _ component: ServerComponent,
        state: SDUIStateManager,
        handler: ActionHandler
    ) -> AnyView {
        let key = component.typeName
        
        guard let factory = factories[key] else {
            return AnyView(
                Text("Unknown: \(key)")
                    .foregroundColor(.red)
                    .font(.caption)
            )
        }
        
        return factory(component, state, handler)
    }
    
    // MARK: - Default Components
    
    private func registerDefaults() {
        register("column") { component, state, handler in
            SDUIColumnView(data: component.columnData!,
                            state: state, handler: handler)
        }
        register("row") { component, state, handler in
            SDUIRowView(data: component.rowData!,
                        state: state, handler: handler)
        }
        register("text") { component, _, _ in
            SDUITextView(data: component.textData!)
        }
        register("image") { component, _, _ in
            SDUIImageView(data: component.imageData!)
        }
        register("button") { component, _, handler in
            SDUIButtonView(data: component.buttonData!,
                           handler: handler)
        }
        register("textField") { component, state, _ in
            SDUITextFieldView(data: component.textFieldData!,
                              state: state)
        }
        register("spacer") { component, _, _ in
            SDUISpacerView(data: component.spacerData!)
        }
        register("card") { component, state, handler in
            SDUICardView(data: component.cardData!,
                         state: state, handler: handler)
        }
        register("scrollView") { component, state, handler in
            SDUIScrollViewWrapper(data: component.scrollViewData!,
                                   state: state, handler: handler)
        }
        register("lazyVStack") { component, state, handler in
            SDUILazyVStackView(data: component.lazyVStackData!,
                               state: state, handler: handler)
        }
    }
}

// MARK: - Convenience Accessors

extension ServerComponent {
    var typeName: String {
        switch self {
        case .column: return "column"
        case .row: return "row"
        case .text: return "text"
        case .image: return "image"
        case .button: return "button"
        case .textField: return "textField"
        case .spacer: return "spacer"
        case .card: return "card"
        case .scrollView: return "scrollView"
        case .lazyVStack: return "lazyVStack"
        }
    }
    
    var columnData: ColumnData? {
        if case .column(let d) = self { return d }
        return nil
    }
    var rowData: RowData? {
        if case .row(let d) = self { return d }
        return nil
    }
    var textData: TextData? {
        if case .text(let d) = self { return d }
        return nil
    }
    var imageData: ImageData? {
        if case .image(let d) = self { return d }
        return nil
    }
    var buttonData: ButtonData? {
        if case .button(let d) = self { return d }
        return nil
    }
    var textFieldData: TextFieldData? {
        if case .textField(let d) = self { return d }
        return nil
    }
    var spacerData: SpacerData? {
        if case .spacer(let d) = self { return d }
        return nil
    }
    var cardData: CardData? {
        if case .card(let d) = self { return d }
        return nil
    }
    var scrollViewData: ScrollViewData? {
        if case .scrollView(let d) = self { return d }
        return nil
    }
    var lazyVStackData: LazyVStackData? {
        if case .lazyVStack(let d) = self { return d }
        return nil
    }
}

On AnyView: Yes, we're using AnyView for type erasure. In a production SDUI system this is the pragmatic choice — the registry must return a uniform type. SwiftUI's diffing algorithm handles it well as long as you provide stable id values (which we do via Identifiable). If you measure real performance issues, consider the @ViewBuilder result-builder approach — but profile first.

Step 3: Create the Rendering Engine

The renderer is the heart of the system. It recursively walks the component tree and instantiates the matching SwiftUI view for each node. Here are the individual component views:

SDUIViews.swift
import SwiftUI

// MARK: - Layout Views

struct SDUIColumnView: View {
    let data: ColumnData
    @ObservedObject var state: SDUIStateManager
    let handler: ActionHandler
    
    @EnvironmentObject private var registry: ComponentRegistry
    
    var body: some View {
        VStack(
            alignment: data.alignment.toSwiftUI(),
            spacing: data.spacing
        ) {
            ForEach(data.children) { child in
                registry.resolve(child, state: state, handler: handler)
            }
        }
        .padding(data.padding)
    }
}

struct SDUIRowView: View {
    let data: RowData
    @ObservedObject var state: SDUIStateManager
    let handler: ActionHandler
    
    @EnvironmentObject private var registry: ComponentRegistry
    
    var body: some View {
        HStack(
            alignment: data.alignment.toSwiftUI(),
            spacing: data.spacing
        ) {
            ForEach(data.children) { child in
                registry.resolve(child, state: state, handler: handler)
            }
        }
    }
}

// MARK: - Content Views

struct SDUITextView: View {
    let data: TextData
    
    var body: some View {
        Text(data.content)
            .font(data.style.toFont())
            .foregroundColor(
                data.color.flatMap { Color(hex: $0) }
            )
    }
}

struct SDUIImageView: View {
    let data: ImageData
    
    var body: some View {
        AsyncImage(url: URL(string: data.url)) { phase in
            switch phase {
            case .success(let image):
                image
                    .resizable()
                    .aspectRatio(
                        contentMode: data.contentMode == .fill
                            ? .fill : .fit
                    )
            case .failure:
                Image(systemName: "photo")
                    .foregroundColor(.gray)
            default:
                ProgressView()
            }
        }
        .frame(width: data.width, height: data.height)
        .clipShape(RoundedRectangle(cornerRadius: data.cornerRadius))
        .accessibilityLabel(data.accessibilityLabel ?? "")
    }
}

struct SDUISpacerView: View {
    let data: SpacerData
    
    var body: some View {
        Spacer()
            .frame(width: data.width, height: data.height)
    }
}

// MARK: - Interactive Views

struct SDUIButtonView: View {
    let data: ButtonData
    let handler: ActionHandler
    
    var body: some View {
        switch data.style {
        case .primary:
            Button(action: { handler.handle(data.action) }) {
                Text(data.label)
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 12)
            }
            .buttonStyle(.borderedProminent)
            .disabled(!data.isEnabled)
            
        case .secondary:
            Button(action: { handler.handle(data.action) }) {
                Text(data.label)
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 12)
            }
            .buttonStyle(.bordered)
            .disabled(!data.isEnabled)
            
        case .destructive:
            Button(role: .destructive,
                   action: { handler.handle(data.action) }) {
                Text(data.label)
            }
            
        case .plain:
            Button(action: { handler.handle(data.action) }) {
                Text(data.label)
            }
            .buttonStyle(.plain)
        }
    }
}

struct SDUITextFieldView: View {
    let data: TextFieldData
    @ObservedObject var state: SDUIStateManager
    
    private var binding: Binding<String> {
        Binding(
            get: { state.getString(data.stateKey) ?? "" },
            set: { state.set(data.stateKey, value: $0) }
        )
    }
    
    var body: some View {
        Group {
            if data.isSecure {
                SecureField(data.placeholder, text: binding)
            } else {
                TextField(data.placeholder, text: binding)
                    .keyboardType(data.keyboardType.toUIKit())
            }
        }
        .textFieldStyle(.roundedBorder)
    }
}

struct SDUICardView: View {
    let data: CardData
    @ObservedObject var state: SDUIStateManager
    let handler: ActionHandler
    
    @EnvironmentObject private var registry: ComponentRegistry
    
    var body: some View {
        let content = VStack(spacing: 8) {
            ForEach(data.children) { child in
                registry.resolve(child, state: state, handler: handler)
            }
        }
        .padding(data.padding)
        .background(Color(.secondarySystemBackground))
        .clipShape(RoundedRectangle(cornerRadius: data.cornerRadius))
        
        if let action = data.action {
            Button(action: { handler.handle(action) }) {
                content
            }
            .buttonStyle(.plain)
        } else {
            content
        }
    }
}

struct SDUIScrollViewWrapper: View {
    let data: ScrollViewData
    @ObservedObject var state: SDUIStateManager
    let handler: ActionHandler
    
    @EnvironmentObject private var registry: ComponentRegistry
    
    var body: some View {
        ScrollView(data.axis == .horizontal ? .horizontal : .vertical) {
            if data.axis == .horizontal {
                HStack {
                    ForEach(data.children) { child in
                        registry.resolve(child, state: state,
                                        handler: handler)
                    }
                }
            } else {
                VStack {
                    ForEach(data.children) { child in
                        registry.resolve(child, state: state,
                                        handler: handler)
                    }
                }
            }
        }
    }
}

struct SDUILazyVStackView: View {
    let data: LazyVStackData
    @ObservedObject var state: SDUIStateManager
    let handler: ActionHandler
    
    @EnvironmentObject private var registry: ComponentRegistry
    
    var body: some View {
        LazyVStack(spacing: data.spacing) {
            ForEach(data.children) { child in
                registry.resolve(child, state: state, handler: handler)
            }
        }
    }
}

SwiftUI Alignment Helpers

AlignmentHelpers.swift
extension HorizontalAlignmentType {
    func toSwiftUI() -> HorizontalAlignment {
        switch self {
        case .leading: return .leading
        case .center: return .center
        case .trailing: return .trailing
        }
    }
}

extension VerticalAlignmentType {
    func toSwiftUI() -> VerticalAlignment {
        switch self {
        case .top: return .top
        case .center: return .center
        case .bottom: return .bottom
        }
    }
}

extension TextStyleType {
    func toFont() -> Font {
        switch self {
        case .largeTitle: return .largeTitle
        case .title: return .title
        case .headline: return .headline
        case .subheadline: return .subheadline
        case .body: return .body
        case .caption: return .caption
        case .footnote: return .footnote
        }
    }
}

extension KeyboardStyleType {
    func toUIKit() -> UIKeyboardType {
        switch self {
        case .default: return .default
        case .email: return .emailAddress
        case .number: return .numberPad
        case .url: return .URL
        }
    }
}

Now we tie it all together with the top-level renderer view:

ServerDrivenView.swift
import SwiftUI

/// The top-level view that renders a server-defined screen.
struct ServerDrivenView: View {
    let screen: ScreenDefinition
    
    @StateObject private var registry = ComponentRegistry()
    @StateObject private var stateManager = SDUIStateManager()
    @StateObject private var actionProcessor = ActionProcessor()
    
    var body: some View {
        registry.resolve(
            screen.root,
            state: stateManager,
            handler: actionProcessor.handler
        )
        .environmentObject(registry)
        .alert(
            actionProcessor.alertTitle,
            isPresented: $actionProcessor.showingAlert
        ) {
            Button("OK", role: .cancel) {}
        } message: {
            if let msg = actionProcessor.alertMessage {
                Text(msg)
            }
        }
        .onAppear {
            stateManager.initialize(screen.initialState)
        }
    }
}

// MARK: - Screen Definition

struct ScreenDefinition: Codable {
    let id: String
    let root: ServerComponent
    var title: String? = nil
    var initialState: [String: AnyCodable] = [:]
}

Step 4: Wire Up Actions & Navigation

When users tap buttons or interact with components, the server-defined actions need to be executed. The ActionProcessor handles this:

ActionProcessor.swift
import SwiftUI

@MainActor
final class ActionProcessor: ObservableObject {
    @Published var showingAlert = false
    @Published var alertTitle = ""
    @Published var alertMessage: String? = nil
    @Published var navigationPath: [String] = []
    @Published var sheetDestination: String? = nil
    
    lazy var handler: ActionHandler = {
        ActionHandler { [weak self] action in
            Task { @MainActor in
                self?.process(action)
            }
        }
    }()
    
    func process(_ action: ServerAction) {
        switch action {
        case .navigate(let nav):
            switch nav.presentation {
            case .push:
                navigationPath.append(nav.destination)
            case .sheet, .fullScreenCover:
                sheetDestination = nav.destination
            }
            
        case .apiCall(let api):
            Task {
                await executeApiCall(api)
            }
            
        case .showAlert(let alert):
            alertTitle = alert.title
            alertMessage = alert.message
            showingAlert = true
            
        case .openURL(let open):
            if let url = URL(string: open.url) {
                UIApplication.shared.open(url)
            }
            
        case .updateState(let update):
            // Handled via stateManager binding
            break
            
        case .sequence(let seq):
            for action in seq.actions {
                process(action)
            }
        }
    }
    
    private func executeApiCall(_ api: ApiCallAction) async {
        guard let url = URL(string: api.endpoint) else { return }
        
        var request = URLRequest(url: url)
        request.httpMethod = api.method
        request.setValue("application/json",
                       forHTTPHeaderField: "Content-Type")
        
        do {
            let (_, response) = try await URLSession.shared.data(for: request)
            guard let http = response as? HTTPURLResponse,
                  (200..<300).contains(http.statusCode) else {
                alertTitle = "Error"
                alertMessage = "Request failed"
                showingAlert = true
                return
            }
        } catch {
            alertTitle = "Network Error"
            alertMessage = error.localizedDescription
            showingAlert = true
        }
    }
}

// MARK: - Action Handler (passed to views)

final class ActionHandler {
    private let onAction: (ServerAction) -> Void
    
    init(_ onAction: @escaping (ServerAction) -> Void) {
        self.onAction = onAction
    }
    
    func handle(_ action: ServerAction) {
        onAction(action)
    }
}

Navigation Integration

Wire the ActionProcessor into SwiftUI's NavigationStack for full programmatic navigation:

SDUINavigationHost.swift
struct SDUINavigationHost: View {
    let screenRepository: ScreenRepository
    let initialScreenId: String
    
    @StateObject private var processor = ActionProcessor()
    
    var body: some View {
        NavigationStack(path: $processor.navigationPath) {
            SDUIScreenLoader(
                screenId: initialScreenId,
                repository: screenRepository
            )
            .navigationDestination(for: String.self) { screenId in
                SDUIScreenLoader(
                    screenId: screenId,
                    repository: screenRepository
                )
            }
        }
        .sheet(item: $processor.sheetDestination) { screenId in
            SDUIScreenLoader(
                screenId: screenId,
                repository: screenRepository
            )
        }
    }
}

// Make String conform to Identifiable for sheet presentation
extension String: @retroactive Identifiable {
    public var id: String { self }
}

Step 5: Manage State

SDUI screens need local state for form inputs, toggles, and conditional rendering. We use an ObservableObject with a dictionary-based store — the server defines state keys, and components read/write through the manager:

SDUIStateManager.swift
import SwiftUI
import Combine

@MainActor
final class SDUIStateManager: ObservableObject {
    @Published private var store: [String: AnyCodable] = [:]
    
    // MARK: - Initialization
    
    func initialize(_ initial: [String: AnyCodable]) {
        store = initial
    }
    
    // MARK: - Typed Getters
    
    func getString(_ key: String) -> String? {
        store[key]?.value as? String
    }
    
    func getBool(_ key: String) -> Bool {
        (store[key]?.value as? Bool) ?? false
    }
    
    func getInt(_ key: String) -> Int? {
        store[key]?.value as? Int
    }
    
    // MARK: - Setter
    
    func set(_ key: String, value: Any) {
        store[key] = AnyCodable(value)
    }
    
    // MARK: - Binding Factory
    
    func binding(for key: String) -> Binding<String> {
        Binding(
            get: { [weak self] in
                self?.getString(key) ?? ""
            },
            set: { [weak self] newValue in
                self?.set(key, value: newValue)
            }
        )
    }
    
    func toggleBinding(for key: String) -> Binding<Bool> {
        Binding(
            get: { [weak self] in
                self?.getBool(key) ?? false
            },
            set: { [weak self] newValue in
                self?.set(key, value: newValue)
            }
        )
    }
    
    // MARK: - Collect state for API calls
    
    func collect(keys: [String]) -> [String: Any] {
        var result: [String: Any] = [:]
        for key in keys {
            if let val = store[key]?.value {
                result[key] = val
            }
        }
        return result
    }
}

The AnyCodable Helper

Since server state values can be any type, we need a type-erased Codable wrapper. You can use the popular AnyCodable package, or roll a minimal version:

AnyCodable.swift
struct AnyCodable: Codable {
    let value: Any
    
    init(_ value: Any) {
        self.value = value
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        
        if let str = try? container.decode(String.self) {
            value = str
        } else if let int = try? container.decode(Int.self) {
            value = int
        } else if let double = try? container.decode(Double.self) {
            value = double
        } else if let bool = try? container.decode(Bool.self) {
            value = bool
        } else {
            value = ""
        }
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        
        switch value {
        case let str as String: try container.encode(str)
        case let int as Int: try container.encode(int)
        case let double as Double: try container.encode(double)
        case let bool as Bool: try container.encode(bool)
        default: try container.encodeNil()
        }
    }
}

Complete Working Example

Let's put it all together. Here's a complete SDUI-driven login screen — from the JSON the server sends, to the networking layer, to the final SwiftUI integration:

Server JSON Response

GET /api/screens/login
{
  "id": "login-screen",
  "title": "Sign In",
  "initialState": {
    "email": "",
    "password": ""
  },
  "root": {
    "type": "scrollView",
    "id": "scroll-root",
    "axis": "vertical",
    "children": [
      {
        "type": "column",
        "id": "main-col",
        "spacing": 24,
        "padding": 32,
        "alignment": "center",
        "children": [
          {
            "type": "spacer",
            "id": "top-spacer",
            "height": 60
          },
          {
            "type": "image",
            "id": "logo",
            "url": "https://example.com/logo.png",
            "width": 80,
            "height": 80,
            "cornerRadius": 16
          },
          {
            "type": "text",
            "id": "welcome",
            "content": "Welcome back",
            "style": "largeTitle"
          },
          {
            "type": "text",
            "id": "subtitle",
            "content": "Sign in to continue",
            "style": "subheadline",
            "color": "#8E8E93"
          },
          {
            "type": "textField",
            "id": "email-input",
            "placeholder": "Email address",
            "stateKey": "email",
            "keyboardType": "email"
          },
          {
            "type": "textField",
            "id": "password-input",
            "placeholder": "Password",
            "stateKey": "password",
            "isSecure": true
          },
          {
            "type": "button",
            "id": "login-btn",
            "label": "Sign In",
            "style": "primary",
            "action": {
              "type": "sequence",
              "actions": [
                {
                  "type": "apiCall",
                  "endpoint": "https://api.example.com/auth/login",
                  "method": "POST",
                  "bodyFromState": ["email", "password"]
                },
                {
                  "type": "navigate",
                  "destination": "home",
                  "presentation": "push"
                }
              ]
            }
          },
          {
            "type": "button",
            "id": "forgot-btn",
            "label": "Forgot password?",
            "style": "plain",
            "action": {
              "type": "navigate",
              "destination": "forgot-password",
              "presentation": "sheet"
            }
          }
        ]
      }
    ]
  }
}

Screen Repository

ScreenRepository.swift
import Foundation

final class ScreenRepository {
    private let baseURL: URL
    private let decoder: JSONDecoder
    private var cache: [String: ScreenDefinition] = [:]
    
    init(baseURL: URL) {
        self.baseURL = baseURL
        self.decoder = JSONDecoder()
    }
    
    func fetchScreen(_ screenId: String) async throws -> ScreenDefinition {
        // Return cached version if available
        if let cached = cache[screenId] {
            return cached
        }
        
        let url = baseURL.appendingPathComponent("screens/\(screenId)")
        let (data, response) = try await URLSession.shared.data(from: url)
        
        guard let http = response as? HTTPURLResponse,
              http.statusCode == 200 else {
            throw SDUIError.networkError
        }
        
        let screen = try decoder.decode(ScreenDefinition.self, from: data)
        cache[screenId] = screen
        return screen
    }
}

enum SDUIError: Error, LocalizedError {
    case networkError
    case decodingError(String)
    case unknownComponent(String)
    
    var errorDescription: String? {
        switch self {
        case .networkError:
            return "Failed to fetch screen from server"
        case .decodingError(let msg):
            return "Failed to decode screen: \(msg)"
        case .unknownComponent(let type):
            return "Unknown component type: \(type)"
        }
    }
}

Screen Loader with Loading/Error States

SDUIScreenLoader.swift
import SwiftUI

struct SDUIScreenLoader: View {
    let screenId: String
    let repository: ScreenRepository
    
    @State private var screen: ScreenDefinition?
    @State private var error: Error?
    @State private var isLoading = true
    
    var body: some View {
        Group {
            if let screen {
                ServerDrivenView(screen: screen)
                    .navigationTitle(screen.title ?? "")
            } else if let error {
                VStack(spacing: 16) {
                    Image(systemName: "exclamationmark.triangle")
                        .font(.largeTitle)
                        .foregroundColor(.orange)
                    Text("Something went wrong")
                        .font(.headline)
                    Text(error.localizedDescription)
                        .font(.caption)
                        .foregroundColor(.secondary)
                    Button("Retry") { Task { await loadScreen() } }
                        .buttonStyle(.bordered)
                }
                .padding()
            } else {
                ProgressView("Loading...")
            }
        }
        .task {
            await loadScreen()
        }
    }
    
    private func loadScreen() async {
        isLoading = true
        error = nil
        
        do {
            screen = try await repository.fetchScreen(screenId)
        } catch {
            self.error = error
        }
        
        isLoading = false
    }
}

App Entry Point

MyApp.swift
import SwiftUI

@main
struct MyApp: App {
    let repository = ScreenRepository(
        baseURL: URL(string: "https://api.example.com")!
    )
    
    var body: some Scene {
        WindowGroup {
            SDUINavigationHost(
                screenRepository: repository,
                initialScreenId: "login"
            )
        }
    }
}

That's a complete, working SDUI system in under 500 lines of Swift. The server sends a JSON tree, the client decodes it into ServerComponent values, the registry maps each type to a SwiftUI view, and the renderer walks the tree recursively.

Pro Tip: For SwiftUI Previews, you don't need a running server. Create static ScreenDefinition instances in your preview code and pass them directly to ServerDrivenView. This makes SDUI screens just as previewable as hardcoded SwiftUI — see the Challenges section for more.

See SDUI in Action

Want to see a complete SDUI-powered app — without writing all this infrastructure yourself? Check out our interactive demo.

Try the Live Demo

Challenges Specific to iOS

Building SDUI on iOS isn't just "the Android tutorial but in Swift." There are platform-specific gotchas that will bite you in production. Here's what to watch for:

1. App Transport Security (ATS)

iOS requires HTTPS by default. If your SDUI server uses HTTP during development, you'll get silent failures. Add this to your Info.plist for local development only:

<!-- Info.plist — DEVELOPMENT ONLY, remove for production -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>

Never ship with NSAllowsArbitraryLoads. Apple will reject your app, and it's a genuine security risk. Use NSAllowsLocalNetworking for development, and ensure your production SDUI endpoint uses HTTPS with a valid certificate. See our SDUI security best practices guide.

2. SwiftUI View Identity and Performance

SwiftUI uses structural identity to diff view trees. When you use AnyView (as we do in the registry), SwiftUI loses the ability to do structural diffing and falls back to comparing by id. This is fine — but only if your IDs are stable.

If the server sends different IDs for the same logical component on each request, SwiftUI will tear down and rebuild the entire view, killing performance and losing state. Rules:

3. NavigationStack vs NavigationView

If you're supporting iOS 15, you're stuck with the deprecated NavigationView and its painful programmatic navigation. Our tutorial uses NavigationStack (iOS 16+), which has proper path-based navigation that works beautifully with SDUI:

// iOS 16+ — clean, works with SDUI naturally
NavigationStack(path: $path) {
    content
        .navigationDestination(for: String.self) { screenId in
            SDUIScreenLoader(screenId: screenId, ...)
        }
}

// iOS 15 — requires NavigationLink hacks
// You'll need invisible NavigationLinks with isActive bindings.
// It's messy. Drop iOS 15 if you can.

4. SwiftUI Previews

One concern teams have: "Can I still use Xcode Previews with SDUI?" Yes — and it's easier than you'd think. Create preview helpers that build ScreenDefinition values directly:

SDUIPreviewHelpers.swift
#if DEBUG
extension ScreenDefinition {
    static var previewLogin: ScreenDefinition {
        ScreenDefinition(
            id: "preview-login",
            root: .column(ColumnData(
                id: "col",
                children: [
                    .text(TextData(
                        id: "title",
                        content: "Welcome back",
                        style: .largeTitle
                    )),
                    .textField(TextFieldData(
                        id: "email",
                        placeholder: "Email",
                        stateKey: "email",
                        keyboardType: .email
                    )),
                    .button(ButtonData(
                        id: "submit",
                        label: "Sign In",
                        action: .showAlert(ShowAlertAction(
                            title: "Tapped!"
                        )),
                        style: .primary
                    ))
                ],
                spacing: 20,
                padding: 24
            )),
            title: "Sign In"
        )
    }
}

struct SDUIPreview: PreviewProvider {
    static var previews: some View {
        NavigationStack {
            ServerDrivenView(screen: .previewLogin)
        }
    }
}
#endif

5. Error Handling & Fallbacks

Unlike Android where Compose is relatively forgiving of unexpected state, SwiftUI will crash hard on force-unwraps and out-of-bounds access. Your SDUI system needs bulletproof error handling. Read our SDUI error handling & fallbacks guide for patterns that work in production. Key rules:

6. Performance: AsyncImage and Large Lists

AsyncImage re-fetches on every recomposition unless you cache. For SDUI screens with many images, use a library like Nuke or Kingfisher instead. For long lists, make sure the server sends lazyVStack instead of column — the lazy variant only instantiates visible rows. See our SDUI performance optimization guide for benchmarks and tuning tips.

Why Teams Choose Pyramid for iOS

Building SDUI from scratch — as we just did — teaches you the fundamentals. But taking it to production means solving dozens of additional problems: testing, versioning, caching, analytics, accessibility, design system synchronization, and more.

That's where Pyramid comes in. Here's what differentiates it from rolling your own:

BYOC (Bring Your Own Components) Compiler

Pyramid doesn't force you to use generic components. Its compiler takes your existing SwiftUI views and generates the SDUI bindings automatically. Your ProductCard, your CheckoutFlow, your design system — Pyramid wraps them, not replaces them. This is a key differentiator we explore in our Pyramid vs. DIY comparison.

Typed DSL (Not Raw JSON)

Instead of hand-crafting JSON (which is fragile and error-prone), Pyramid uses a typed DSL that catches errors at author time, not runtime. Your backend team writes type-safe screen definitions that can't produce invalid component trees.

Shared Backend: iOS + Android

Write one screen definition, render natively on both platforms. The same Pyramid backend serves both your SwiftUI iOS app and your Jetpack Compose Android app. No duplication, no drift, one source of truth.

Production-Ready Out of the Box

Want to evaluate whether building or buying makes sense for your team? Our SDUI ROI calculator and readiness assessment can help you make a data-driven decision. And if you're planning a migration, check out the migration planner.

Wrapping Up

You now have a complete, working server-driven UI system for iOS in SwiftUI:

Next steps for taking this to production:

  1. Add more components (toggle, picker, divider, badge, etc.)
  2. Implement disk caching with UserDefaults or Core Data for offline support
  3. Add schema versioning so old clients gracefully handle new component types
  4. Set up analytics to track which server-driven screens users interact with
  5. Read the SDUI adoption playbook for team rollout best practices

If this tutorial's Android counterpart helped your Android team, share the Compose SDUI tutorial with them — the architecture is intentionally parallel so both platforms speak the same language.

Related Articles

Skip the Build Phase

Pyramid provides a production-ready SDUI SDK with 50+ SwiftUI components, visual editor, and built-in A/B testing. Ship your first server-driven screen in days, not months.

Join the Waitlist