Server-Driven UI with Uniform and SwiftUI

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.