Server-Driven UI for iOS: Building Dynamic SwiftUI Screens
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
- Why iOS Teams Need SDUI
- The iOS SDUI Landscape
- Architecture Overview
- Step 1: Define the Component Model
- Step 2: Build the Component Registry
- Step 3: Create the Rendering Engine
- Step 4: Wire Up Actions & Navigation
- Step 5: Manage State
- Complete Working Example
- Challenges Specific to iOS
- 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:
- App Store review delays: Average review time is 24-48 hours, but rejections can add days or weeks. A critical UI fix that takes 10 minutes to code can take a week to reach users.
- No partial rollouts for UI: You can do phased rollouts of binary updates, but you can't A/B test a new onboarding flow without shipping two code paths in the binary.
- Slow iteration cycles: Build → Submit → Review → Release → Wait for users to update. By the time your experiment reaches statistical significance, the quarter is over.
- Cross-platform parity: If your Android team ships SDUI and your iOS team doesn't, you'll have permanent feature drift between platforms.
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:
- Server: Sends a JSON (or typed DSL) component tree describing the screen
- Component Model: Swift types (
Codableenums) representing every possible UI element - Registry: Maps component types to SwiftUI
Viewimplementations - 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.
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:
// 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:
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:
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:
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:
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:
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
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:
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:
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:
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:
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:
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
{
"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
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
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
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 DemoChallenges 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:
- Stable IDs: Server must send the same
idfor the same logical component across requests - Don't use
UUID()on the server: Generate deterministic IDs (e.g.,"login-email-field") - Avoid deep nesting: Flatter component trees diff faster. If a screen has 100+ nested components, consider pagination
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:
#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:
- Never force-unwrap server data — always use optional chaining or defaults
- Bundle fallback screens in the app binary for critical paths (login, home, settings)
- Log unknown component types instead of crashing — render a placeholder or nothing
- Version your schema: When you add new component types, old clients need to gracefully skip them
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
- 50+ pre-built components with accessibility baked in
- Built-in A/B testing with statistical significance tracking
- Visual editor for non-engineers to modify screens
- Schema versioning with backward compatibility
- Offline caching and graceful degradation
- Analytics integration (Amplitude, Mixpanel, Firebase)
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:
- ✅ Type-safe component model with
Codableenums - ✅ Extensible registry connecting types to SwiftUI views
- ✅ Recursive renderer that handles nested component trees
- ✅ Full action system: navigation, API calls, alerts, deep links
- ✅ Centralized state management with
ObservableObject - ✅
NavigationStackintegration for programmatic routing - ✅ Loading states, error handling, and retry logic
Next steps for taking this to production:
- Add more components (toggle, picker, divider, badge, etc.)
- Implement disk caching with
UserDefaultsor Core Data for offline support - Add schema versioning so old clients gracefully handle new component types
- Set up analytics to track which server-driven screens users interact with
- 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
- What is Server-Driven UI? Complete Guide →
- SDUI Tutorial: Building Dynamic Screens with Jetpack Compose →
- SDUI Architecture Patterns: Building Scalable Systems →
- Why Raw JSON Breaks SDUI (And What to Use Instead) →
- SDUI Frameworks Compared: 2026 Edition →
- The Business Case for Server-Driven UI →
- SDUI Error Handling & Fallback Strategies →
- SDUI Security Best Practices →
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