Server-Driven UI with Uniform and SwiftUI
Last updated: March 12, 2026
This guide shows how to build a server-driven component rendering system for iOS using Uniform and SwiftUI. The pattern lets your marketing team control which components appear, in what order, and with what content — while your iOS app handles rendering natively.
How Uniform Compositions Work
A Uniform composition is a page-level document that contains slots — ordered arrays of components. Each component has:
type— a string identifier (e.g."promotionBanner","featureGrid","testimonial")parameters— key-value pairs for content (text, links, images)slots— optional nested child components
{
"composition": {
"slots": {
"mainContent": [
{
"type": "promotionBanner",
"parameters": {
"title": {
"value": "Promo just for you"
}
}
}
]
}
}
}
The order of components in the slot is the order they should render. Adding, removing, or reordering components in Uniform is immediately reflected in the API response.
The Pattern: Component Registry
The core challenge is mapping an arbitrary list of JSON components to native SwiftUI views. The pattern has three layers:
┌─────────────────────────────────────────────────────┐
│ API Response: [SlotComponent] │
│ Generic Codable structs, same for every project │
└──────────────────────┬──────────────────────────────┘
│
ComponentRegistry
(one file, two methods)
│
┌─────────────┼─────────────┐
▼ ▼ ▼
mapComponent mapComponent mapComponent
"hero" → .hero "grid" → .grid ? → nil
│ │
▼ ▼
MainContentItem enum (type-safe, Identifiable)
│ │
view(for:) view(for:)
│ │
▼ ▼
HeroView GridView
Layer 1: Decoding (generic, reusable across projects)
These structs match Uniform’s API shape and don’t change between projects:
/// A single component in a Uniform slot.
struct SlotComponent: Codable {
let type: String
let parameters: ComponentParameters?
let slots: ChildSlots?
}
/// Union of all parameter fields used across your component types.
/// Unknown fields decode as nil.
struct ComponentParameters: Codable {
let title: ParameterValue?
let description: ParameterValue?
let ctaText: ParameterValue?
let ctaLink: LinkParameterValue?
// Add fields as you register new component types.
}
/// Uniform wraps every parameter value with a type discriminator.
struct ParameterValue: Codable {
let type: String // e.g. "text", "number"
let value: String? // Optional: some types (assets, links) use a different shape
}
Tip: Make ParameterValue.value optional. Different parameter types (text, asset, link) have different value shapes. Keeping it optional prevents a single unexpected field from breaking the entire decode.
Layer 2: Type-safe content enum
Each case carries a domain-specific view model. Using an enum (instead of a protocol with AnyView) preserves SwiftUI’s diffing and avoids type erasure:
enum MainContentItem: Identifiable {
case promotionBanner(PromotionBannerViewModel)
case featureGrid(FeatureGridViewModel)
case testimonial(TestimonialViewModel)
var id: String {
switch self {
case .promotionBanner: return "promotionBanner"
case .featureGrid: return "featureGrid"
case .testimonial: return "testimonial"
}
}
}
Layer 3: Component Registry
One file, two static methods — this is where all the wiring lives:
import SwiftUI
struct ComponentRegistry {
/// Maps a raw API component to a typed content item.
/// Returns nil for unrecognized types (filtered out by compactMap).
static func mapComponent(_ component: SlotComponent) -> MainContentItem? {
switch component.type {
case "promotionBanner":
return .promotionBanner(PromotionBannerViewModel(
title: component.parameters?.title?.value ?? ""
))
case "featureGrid":
// Extract nested slot children if needed
return .featureGrid(FeatureGridViewModel(/* ... */))
default:
return nil
}
}
/// Renders a content item as a SwiftUI view.
@ViewBuilder
static func view(for item: MainContentItem) -> some View {
switch item {
case .promotionBanner(let vm):
PromotionBannerView(viewModel: vm)
case .featureGrid(let vm):
FeatureGridView(viewModel: vm)
.padding(.horizontal, 16)
case .testimonial(let vm):
TestimonialView(viewModel: vm)
.padding(16)
}
}
}
The compiler enforces completeness — add a new enum case and both switch statements must be updated or the build fails.
Wiring It Up
Service layer
class ContentService: ObservableObject {
@Published var contentItems: [MainContentItem] = []
func fetchComposition() async {
let response = // ... fetch and decode UniformCompositionResponse
let mainContent = response.composition.slots.mainContent
await MainActor.run {
self.contentItems = mainContent.compactMap {
ComponentRegistry.mapComponent($0)
}
}
}
}
View layer
ScrollView {
VStack(spacing: 0) {
ForEach(service.contentItems) { item in
ComponentRegistry.view(for: item)
}
}
}
That’s it. The API controls presence and order. The registry maps types. The views render natively.
Adding a New Component Type
StepFileAction | ||
1 |
| Create |
2 |
| Add |
3 |
| Create SwiftUI view |
4 |
| Add data mapping + view mapping |
No changes needed in the service or the main view — compactMap and ForEach handle everything generically.
Design Decisions
DecisionRationale | |
Enum with associated values over protocol + AnyView | Preserves SwiftUI’s view diffing. |
| Unknown component types are silently skipped instead of crashing. Cleaner one-liner. |
Optional | Different Uniform field types (text, asset, link) have incompatible value shapes. Optionality makes decoding resilient. |
Union | Adding a field is a one-line change. The JSON decoder ignores keys not present in the struct, so extra fields from new component types don’t break existing ones. |
Single | Keeps the “what type maps to what” knowledge in one place. Easy to audit, easy to onboard new team members. |