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

Models.swift

Create NewViewModel struct

2

Models.swift

Add .newType(NewViewModel) to MainContentItem

3

NewView.swift

Create SwiftUI view

4

ComponentRegistry.swift

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. AnyView disables structural identity and hurts performance.

compactMap over forEach + append

Unknown component types are silently skipped instead of crashing. Cleaner one-liner.

Optional ParameterValue.value

Different Uniform field types (text, asset, link) have incompatible value shapes. Optionality makes decoding resilient.

Union ComponentParameters struct

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 ComponentRegistry file

Keeps the “what type maps to what” knowledge in one place. Easy to audit, easy to onboard new team members.