Skip to content
Flow-UILive
On this page

How to build server driven UI in SwiftUI

Build server driven UI in SwiftUI with four pieces: a JSON envelope, a widget registry, a renderer, and host networking. Worked path using Flow-UI.

Ayush MishraPublished 4 min read

Build server driven UI in SwiftUI with four pieces: a JSON envelope, a widget registry, a renderer, and host networking. Tutorials on Medium and Pyramid almost always show a closed switch on a Codable enum. That is a weekend. This path is the one that still works when the backend ships a type you have not named yet.

Flow-UI is the worked example. You can copy the seams into your own package. The seams matter more than the brand.

The four pieces#

  1. Envelope. A page document: id, sections, widgets, optional nav, header, footer, pagination, refresh.
  2. Registry. A map from type string to a SwiftUI view. Open. Last writer wins.
  3. Renderer. Walks sections, contains unknown types, applies layout chrome in a fixed order.
  4. Loader. Host code that returns Data. The framework does not fetch.

Skip any one of these and you will rebuild it under a different name six months later.

Minimal JSON page#

Start with one section and one widget you already understand. A title block is enough.

json
{
  "id": "hello",
  "sections": [
    {
      "id": "main",
      "widgets": [
        {
          "type": "title_block",
          "id": "hero",
          "data": {
            "title": { "text": "Server driven hello" }
          }
        }
      ]
    }
  ]
}

Do not design thirty widget types before the first page renders. Agree the envelope. Add types when a real surface needs them.

Register one widget#

Starter widgets exist so you can see a page immediately. FlowWidgets.register(on:) puts title block, image text card, banner, button row, tag rail, stepper row, accordion, and separator into the registry.

Your product widget is three files in the app target: payload model (WidgetContent), view (WidgetView), one register call.

OrderCardWidget.swiftswift
struct OrderCardWidget: WidgetView {
    let content: OrderCardContent
    let context: WidgetContext
 
    var body: some View {
        Text(content.orderNumber.text)
    }
}

The payload model owns static let widgetType = "order_card" and the data shape. Reuse TextData and friends from FlowCore so fonts, colors, and dark hex work without a custom theme on day one.

swift
registry.register(OrderCardWidget.self)

Do not apply padding, background, or corners inside the view. The backend sends those on layout. The renderer applies them around your body. If you pad twice, every page looks wrong and nobody knows which layer to fix.

Render FlowPageView#

PageStore owns the decoded page, load state, refresh, and pagination. FlowPageView draws nav, bars, sections, sheets, and toasts.

HomePage.swiftswift
let registry = WidgetRegistry()
FlowWidgets.register(on: registry)
registry.register(OrderCardWidget.self)
 
let store = PageStore(pageID: "home", loader: APILoader(), registry: registry)
FlowPageView(store: store)

iOS 17 is the floor. Observation is how the view tree stays in sync. There is no iOS 16 shim in the package.

Host PageLoader#

swift
struct APILoader: PageLoader {
    func loadPage(_ request: PageRequest) async throws -> Data {
        switch request.kind {
        case .initial, .refresh:
            return try await client.get("/pages/\(request.pageID)")
        case .nextPage(let postback):
            return try await client.post("/pages/\(request.pageID)/next", body: postback)
        case .action(let payload):
            return try await client.post("/actions", body: payload)
        }
    }
}

Return raw bytes. Decoding is FlowCore's job. If you decode twice, you will fight diagnostics.

What you still cannot do without a release#

A new widget type is Swift. Ship the binary, then let the backend place it.

A new action type is an ActionHandler you register on ActionDispatcher. Built-ins already cover toast, dismiss, refresh, bottom sheet, and api mutations. Deeplinks are yours. There is no router in the package.

A WebView is still allowed as a destination. It is not the page.

A note on tests#

You can unit test decode without a window: FlowCore does not import SwiftUI. Feed fixture Data through the same decoder the store uses. UI tests still belong on FlowPageView for gestures. Do not skip decode tests because "it is just JSON". Lossy behaviour is a contract.

Closed enum versus this path#

A closed enum cannot decode hologram_projector. You either crash, fail the whole page, or add a default case that throws away the payload with no diagnostics.

An open registry plus LossyArray drops the unknown child, records a key path, and renders the rest. Debug shows a placeholder. Release skips. That is the behaviour you want on old binaries.

Questions this article should close#

Can I skip FlowWidgets?#

Yes. Register only your types. Starters are examples. Last writer wins if you keep them and override.

Where do images load from?#

Host FlowImageLoader. The package is not Kingfisher and not a CDN.

How do I preview in Xcode?#

Hand a fixture PageLoader that returns Data from a JSON file. WidgetContext.inert() exists for widget previews. You still want one FlowPageView preview that loads a full envelope.

The Flow-UI mark: two nodes joined through a filled centre

Next#

How to build server driven UI in SwiftUI: four seams, one registry call, bytes from the host. Everything else is product taste.

More on the blog.

Read the source

Flow-UI is MIT licensed. The schema, renderer and starter widgets live on GitHub.

GitHub

Get started with Flow-UI

Install the Swift package, register a widget, and render a page from JSON.

Get started