Server-Driven UI Tutorial: Building Dynamic Screens with SwiftUI
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
Understanding the Architecture
A server-driven UI system has four core pieces:
- Schema: A contract defining what components exist and their properties
- Server: Sends JSON describing which components to render
- Registry: Maps component types to SwiftUI View implementations
- 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:
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:
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:
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
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:
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:
{
"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"
}
}
]
}
}
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:
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:
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:
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:
// 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:
- ✅ Type-safe component schema with Swift enums
- ✅ Flexible registry connecting types to SwiftUI Views
- ✅ Recursive renderer handling nested structures
- ✅ Action system for handling user interactions
- ✅ State management using SwiftUI's environment
- ✅ Caching strategies for offline support
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
- Server-Driven UI for Android: A Complete Implementation Guide →
- Getting Started with Pyramid: Add Server-Driven UI to Your Android App in 30 Minutes →
- SDUI vs Cross-Platform: Which Solves Your Mobile Release Problem? →
- SDUI Architecture Patterns: Building Scalable Server-Driven UI Systems →
- SDUI Performance Optimization: Making Server-Driven UI Fast →
- Server-Driven UI for SwiftUI: A Practical Guide for iOS Teams →
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