Server-Driven UI Tutorial: Building Dynamic Screens with SwiftUI

Published: March 14, 2026 18 min read Intermediate

Server-Driven UI (SDUI) lets you update your iOS app's interface without submitting a new version to the App Store. In this hands-on tutorial, we'll build a working SDUI system using SwiftUI.

By the end, you'll have a flexible architecture that can render any screen defined by your backend — ready for A/B tests, instant updates, and rapid iteration.

Also available: Check out our Android/Jetpack Compose SDUI tutorial for the same patterns on Android.

What You'll Learn

  1. Understanding the Architecture
  2. Defining Your Component Schema
  3. Building the Component Registry
  4. Creating the Rendering Engine
  5. Setting Up Networking
  6. Handling Actions and Events
  7. Managing State
  8. Advanced Patterns

Understanding the Architecture

A server-driven UI system has four core pieces:

  1. Schema: A contract defining what components exist and their properties
  2. Server: Sends JSON describing which components to render
  3. Registry: Maps component types to SwiftUI View implementations
  4. Renderer: Transforms server JSON into actual UI

The beauty of this approach: your app ships with all possible components pre-built, but the arrangement and content comes from the server. Change the server response, change the UI — no app update required.

Key Insight: Think of SDUI like HTML. Safari knows how to render <div>, <button>, <img>. The server sends the document structure. Same principle, native performance.

Defining Your Component Schema

First, define an enum hierarchy representing your UI components. Swift's powerful enum system with associated values is perfect for this:

UIComponent.swift
import Foundation

// MARK: - Component Schema

enum UIComponent: Identifiable, Codable {
    var id: String {
        switch self {
        case .column(let c): return c.id
        case .row(let r): return r.id
        case .text(let t): return t.id
        case .image(let i): return i.id
        case .button(let b): return b.id
        case .textField(let tf): return tf.id
        }
    }
    
    // Layout Components
    case column(ColumnComponent)
    case row(RowComponent)
    
    // Content Components
    case text(TextComponent)
    case image(ImageComponent)
    
    // Interactive Components
    case button(ButtonComponent)
    case textField(TextFieldComponent)
}

// MARK: - Component Definitions

struct ColumnComponent: Codable {
    let id: String
    let children: [UIComponent]
    var spacing: CGFloat = 8
    var padding: CGFloat = 0
    var alignment: HorizontalAlignment = .leading
}

struct RowComponent: Codable {
    let id: String
    let children: [UIComponent]
    var spacing: CGFloat = 8
    var alignment: VerticalAlignment = .center
}

struct TextComponent: Codable {
    let id: String
    let content: String
    var style: TextStyle = .body
}

struct ImageComponent: Codable {
    let id: String
    let url: String
    var contentDescription: String?
    var width: CGFloat?
    var height: CGFloat?
}

struct ButtonComponent: Codable {
    let id: String
    let label: String
    let action: UIAction
    var style: ButtonStyle = .primary
}

struct TextFieldComponent: Codable {
    let id: String
    let placeholder: String
    let stateKey: String
}

// MARK: - Enums

enum TextStyle: String, Codable {
    case headline, title, body, caption
}

enum ButtonStyle: String, Codable {
    case primary, secondary, text
}

Defining Actions

Actions describe what happens when users interact with components. Define them as another enum:

UIAction.swift
enum UIAction: Codable {
    case navigate(destination: String, params: [String: String] = [:])
    case apiCall(endpoint: String, method: String = "POST", bodyFromState: [String] = [])
    case updateState(key: String, value: AnyCodable)
    case showAlert(title: String, message: String)
    case sequence(actions: [UIAction])
    case haptic(style: HapticStyle)
}

enum HapticStyle: String, Codable {
    case light, medium, heavy, success, warning, error
}

// Type-erased Codable wrapper for arbitrary values
struct AnyCodable: Codable {
    let value: Any
    
    init(_ value: Any) {
        self.value = value
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let string = try? container.decode(String.self) {
            value = string
        } else if let int = try? container.decode(Int.self) {
            value = int
        } else if let bool = try? container.decode(Bool.self) {
            value = bool
        } else {
            value = NSNull()
        }
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        if let string = value as? String {
            try container.encode(string)
        } else if let int = value as? Int {
            try container.encode(int)
        } else if let bool = value as? Bool {
            try container.encode(bool)
        }
    }
}

Building the Component Registry

In SwiftUI, we use @ViewBuilder and type erasure to map component types to Views. This is where your existing design system connects to SDUI:

ComponentRegistry.swift
import SwiftUI

// MARK: - Registry Protocol

protocol ComponentRenderer {
    @ViewBuilder
    func render(
        _ component: UIComponent,
        actionHandler: @escaping (UIAction) -> Void
    ) -> some View
}

// MARK: - Default Registry

struct DefaultComponentRegistry: ComponentRenderer {
    
    @ViewBuilder
    func render(
        _ component: UIComponent,
        actionHandler: @escaping (UIAction) -> Void
    ) -> some View {
        switch component {
        case .column(let column):
            SDUIColumn(
                component: column,
                renderer: self,
                actionHandler: actionHandler
            )
            
        case .row(let row):
            SDUIRow(
                component: row,
                renderer: self,
                actionHandler: actionHandler
            )
            
        case .text(let text):
            SDUIText(component: text)
            
        case .image(let image):
            SDUIImage(component: image)
            
        case .button(let button):
            SDUIButton(
                component: button,
                actionHandler: actionHandler
            )
            
        case .textField(let textField):
            SDUITextField(component: textField)
        }
    }
}

Implementing Component Views

Components.swift
import SwiftUI

// MARK: - Layout Components

struct SDUIColumn<R: ComponentRenderer>: View {
    let component: ColumnComponent
    let renderer: R
    let actionHandler: (UIAction) -> Void
    
    var body: some View {
        VStack(alignment: component.alignment, spacing: component.spacing) {
            ForEach(component.children) { child in
                renderer.render(child, actionHandler: actionHandler)
            }
        }
        .padding(component.padding)
    }
}

struct SDUIRow<R: ComponentRenderer>: View {
    let component: RowComponent
    let renderer: R
    let actionHandler: (UIAction) -> Void
    
    var body: some View {
        HStack(alignment: component.alignment, spacing: component.spacing) {
            ForEach(component.children) { child in
                renderer.render(child, actionHandler: actionHandler)
            }
        }
    }
}

// MARK: - Content Components

struct SDUIText: View {
    let component: TextComponent
    
    var body: some View {
        Text(component.content)
            .font(fontForStyle(component.style))
    }
    
    private func fontForStyle(_ style: TextStyle) -> Font {
        switch style {
        case .headline: return .headline
        case .title: return .title
        case .body: return .body
        case .caption: return .caption
        }
    }
}

struct SDUIImage: View {
    let component: ImageComponent
    
    var body: some View {
        AsyncImage(url: URL(string: component.url)) { phase in
            switch phase {
            case .empty:
                ProgressView()
            case .success(let image):
                image
                    .resizable()
                    .aspectRatio(contentMode: .fit)
            case .failure:
                Image(systemName: "photo")
                    .foregroundColor(.gray)
            @unknown default:
                EmptyView()
            }
        }
        .frame(
            width: component.width,
            height: component.height
        )
        .accessibilityLabel(
            component.contentDescription ?? "Image"
        )
    }
}

// MARK: - Interactive Components

struct SDUIButton: View {
    let component: ButtonComponent
    let actionHandler: (UIAction) -> Void
    
    var body: some View {
        switch component.style {
        case .primary:
            Button(action: { actionHandler(component.action) }) {
                Text(component.label)
                    .fontWeight(.semibold)
                    .foregroundColor(.white)
                    .padding(.horizontal, 24)
                    .padding(.vertical, 12)
                    .background(Color.accentColor)
                    .cornerRadius(8)
            }
            
        case .secondary:
            Button(action: { actionHandler(component.action) }) {
                Text(component.label)
                    .fontWeight(.semibold)
                    .foregroundColor(.accentColor)
                    .padding(.horizontal, 24)
                    .padding(.vertical, 12)
                    .overlay(
                        RoundedRectangle(cornerRadius: 8)
                            .stroke(Color.accentColor, lineWidth: 2)
                    )
            }
            
        case .text:
            Button(component.label) {
                actionHandler(component.action)
            }
        }
    }
}

Creating the Rendering Engine

The renderer ties everything together. It provides the registry to the view hierarchy using SwiftUI's environment:

SDUIRenderer.swift
import SwiftUI

// MARK: - Screen Definition

struct ScreenDefinition: Codable {
    let id: String
    let root: UIComponent
    var initialState: [String: AnyCodable] = [:]
}

// MARK: - SDUI Screen View

struct SDUIScreen: View {
    let screen: ScreenDefinition
    let onAction: (UIAction) -> Void
    
    private let registry = DefaultComponentRegistry()
    
    var body: some View {
        registry.render(screen.root, actionHandler: onAction)
    }
}

// MARK: - Screen Loading View

struct SDUIScreenLoader: View {
    let screenId: String
    
    @StateObject private var viewModel: SDUIViewModel
    
    init(screenId: String, repository: ScreenRepository) {
        self.screenId = screenId
        self._viewModel = StateObject(
            wrappedValue: SDUIViewModel(repository: repository)
        )
    }
    
    var body: some View {
        Group {
            switch viewModel.state {
            case .loading:
                ProgressView()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                
            case .loaded(let screen):
                SDUIScreen(screen: screen) { action in
                    Task {
                        await viewModel.handleAction(action)
                    }
                }
                
            case .error(let error):
                VStack(spacing: 16) {
                    Image(systemName: "exclamationmark.triangle")
                        .font(.largeTitle)
                        .foregroundColor(.red)
                    Text(error.localizedDescription)
                        .multilineTextAlignment(.center)
                    Button("Retry") {
                        Task { await viewModel.loadScreen(screenId) }
                    }
                }
                .padding()
            }
        }
        .task {
            await viewModel.loadScreen(screenId)
        }
    }
}

Setting Up Networking

Now let's fetch screen definitions from your server. Swift's Codable protocol handles JSON decoding elegantly:

Example JSON Response
{
  "id": "welcome-screen",
  "root": {
    "type": "column",
    "id": "main-column",
    "spacing": 16,
    "padding": 24,
    "children": [
      {
        "type": "text",
        "id": "headline",
        "content": "Welcome back!",
        "style": "headline"
      },
      {
        "type": "button",
        "id": "cta",
        "label": "Get Started",
        "action": {
          "type": "navigate",
          "destination": "dashboard"
        }
      }
    ]
  }
}
ScreenRepository.swift
import Foundation

protocol ScreenRepository {
    func fetchScreen(_ id: String) async throws -> ScreenDefinition
}

final class NetworkScreenRepository: ScreenRepository {
    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder
    
    init(
        baseURL: URL,
        session: URLSession = .shared
    ) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = JSONDecoder()
        self.decoder.keyDecodingStrategy = .convertFromSnakeCase
    }
    
    func fetchScreen(_ id: String) async throws -> ScreenDefinition {
        let url = baseURL.appendingPathComponent("screens/\(id)")
        let (data, response) = try await session.data(from: url)
        
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw SDUIError.networkError
        }
        
        return try decoder.decode(ScreenDefinition.self, from: data)
    }
}

enum SDUIError: Error {
    case networkError
    case decodingError
    case unknownComponent
}

Handling Actions and Events

The view model processes actions when users interact with components:

SDUIViewModel.swift
import SwiftUI

@MainActor
final class SDUIViewModel: ObservableObject {
    
    enum State {
        case loading
        case loaded(ScreenDefinition)
        case error(Error)
    }
    
    @Published var state: State = .loading
    @Published var screenState: [String: Any] = [:]
    @Published var alertInfo: AlertInfo?
    
    private let repository: ScreenRepository
    private let navigator: Navigator?
    private let apiClient: APIClient?
    
    init(
        repository: ScreenRepository,
        navigator: Navigator? = nil,
        apiClient: APIClient? = nil
    ) {
        self.repository = repository
        self.navigator = navigator
        self.apiClient = apiClient
    }
    
    func loadScreen(_ id: String) async {
        state = .loading
        
        do {
            let screen = try await repository.fetchScreen(id)
            
            // Initialize state from screen definition
            for (key, value) in screen.initialState {
                screenState[key] = value.value
            }
            
            state = .loaded(screen)
        } catch {
            state = .error(error)
        }
    }
    
    func handleAction(_ action: UIAction) async {
        switch action {
        case .navigate(let destination, let params):
            navigator?.navigate(to: destination, params: params)
            
        case .apiCall(let endpoint, let method, let bodyFromState):
            let body = bodyFromState.reduce(into: [String: Any]()) { result, key in
                result[key] = screenState[key]
            }
            try? await apiClient?.call(endpoint: endpoint, method: method, body: body)
            
        case .updateState(let key, let value):
            screenState[key] = value.value
            
        case .showAlert(let title, let message):
            alertInfo = AlertInfo(title: title, message: message)
            
        case .sequence(let actions):
            for action in actions {
                await handleAction(action)
            }
            
        case .haptic(let style):
            triggerHaptic(style)
        }
    }
    
    private func triggerHaptic(_ style: HapticStyle) {
        let generator: UIImpactFeedbackGenerator
        
        switch style {
        case .light:
            generator = UIImpactFeedbackGenerator(style: .light)
        case .medium:
            generator = UIImpactFeedbackGenerator(style: .medium)
        case .heavy:
            generator = UIImpactFeedbackGenerator(style: .heavy)
        case .success, .warning, .error:
            let notificationGenerator = UINotificationFeedbackGenerator()
            let type: UINotificationFeedbackGenerator.FeedbackType = 
                style == .success ? .success : (style == .warning ? .warning : .error)
            notificationGenerator.notificationOccurred(type)
            return
        }
        
        generator.impactOccurred()
    }
}

struct AlertInfo: Identifiable {
    let id = UUID()
    let title: String
    let message: String
}

Managing State

SDUI screens often need local state — form inputs, toggles, selections. Use the environment to share state:

SDUIState.swift
import SwiftUI

// MARK: - State Manager

final class SDUIStateManager: ObservableObject {
    @Published var values: [String: Any] = [:]
    
    func initialize(_ initial: [String: Any]) {
        values = initial
    }
    
    func get<T>(_ key: String, default defaultValue: T) -> T {
        (values[key] as? T) ?? defaultValue
    }
    
    func set(_ key: String, value: Any) {
        values[key] = value
    }
    
    func binding(for key: String) -> Binding<String> {
        Binding(
            get: { self.get(key, default: "") },
            set: { self.set(key, value: $0) }
        )
    }
}

// MARK: - Environment Key

private struct SDUIStateKey: EnvironmentKey {
    static let defaultValue = SDUIStateManager()
}

extension EnvironmentValues {
    var sduiState: SDUIStateManager {
        get { self[SDUIStateKey.self] }
        set { self[SDUIStateKey.self] = newValue }
    }
}

// MARK: - TextField with State Binding

struct SDUITextField: View {
    let component: TextFieldComponent
    
    @Environment(\.sduiState) private var stateManager
    
    var body: some View {
        TextField(
            component.placeholder,
            text: stateManager.binding(for: component.stateKey)
        )
        .textFieldStyle(.roundedBorder)
    }
}

Advanced Patterns

Caching for Offline Support

SDUI adds a server dependency. Mitigate this with caching:

CachingScreenRepository.swift
final class CachingScreenRepository: ScreenRepository {
    private let network: ScreenRepository
    private let cache: NSCache<NSString, ScreenWrapper>
    private let fileManager: FileManager
    private let cacheDirectory: URL
    
    init(network: ScreenRepository) {
        self.network = network
        self.cache = NSCache()
        self.fileManager = .default
        self.cacheDirectory = fileManager
            .urls(for: .cachesDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("sdui")
    }
    
    func fetchScreen(_ id: String) async throws -> ScreenDefinition {
        // Check memory cache first
        if let cached = cache.object(forKey: id as NSString) {
            // Refresh in background
            Task { try? await refreshScreen(id) }
            return cached.screen
        }
        
        // Check disk cache
        if let diskCached = loadFromDisk(id) {
            cache.setObject(ScreenWrapper(diskCached), forKey: id as NSString)
            Task { try? await refreshScreen(id) }
            return diskCached
        }
        
        // Fetch from network
        let screen = try await network.fetchScreen(id)
        cacheScreen(screen)
        return screen
    }
    
    private func refreshScreen(_ id: String) async throws {
        let screen = try await network.fetchScreen(id)
        cacheScreen(screen)
    }
    
    private func cacheScreen(_ screen: ScreenDefinition) {
        cache.setObject(ScreenWrapper(screen), forKey: screen.id as NSString)
        saveToDisk(screen)
    }
    
    // ... disk persistence methods
}

private class ScreenWrapper {
    let screen: ScreenDefinition
    init(_ screen: ScreenDefinition) { self.screen = screen }
}

Conditional Rendering (A/B Tests)

Add a conditional component for feature flags and experiments:

ConditionalComponent.swift
// Add to UIComponent enum
case conditional(ConditionalComponent)

struct ConditionalComponent: Codable {
    let id: String
    let condition: String  // Feature flag name
    let whenTrue: UIComponent
    var whenFalse: UIComponent?
}

// In the registry
struct SDUIConditional<R: ComponentRenderer>: View {
    let component: ConditionalComponent
    let renderer: R
    let actionHandler: (UIAction) -> Void
    
    @Environment(\.featureFlags) private var featureFlags
    
    var body: some View {
        if featureFlags.isEnabled(component.condition) {
            renderer.render(component.whenTrue, actionHandler: actionHandler)
        } else if let whenFalse = component.whenFalse {
            renderer.render(whenFalse, actionHandler: actionHandler)
        }
    }
}

Custom Components

Extend your registry with domain-specific components:

// Add to UIComponent enum
case productCard(ProductCardComponent)

struct ProductCardComponent: Codable {
    let id: String
    let productId: String
    let title: String
    let price: String
    let imageUrl: String
    let onTap: UIAction
}

// Add case to registry switch
case .productCard(let product):
    ProductCardView(
        component: product,
        onTap: { actionHandler(product.onTap) }
    )

Best Practice: Keep your component set small and intentional. Too many components = complexity. Start with 10-15 core components covering layout, content, and interaction. Expand only when needed.

Wrapping Up

You now have the foundation for a server-driven UI system in SwiftUI:

Next steps: Add more components to your registry, implement persistence for offline access, and integrate with your feature flag system for A/B testing.

Related Articles

Skip the Build Phase

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

Join the Waitlist

Related Articles