Skip to content
Flow-UILive
On this page

Server driven UI architecture

A server driven UI architecture on iOS is decode, registry, contain failures, render widgets, dispatch actions. Flow-UI names those types.

Ayush MishraPublished 6 min read

A server driven UI architecture on iOS is a pipeline, not a slogan. Decode a page. Look up widgets in a registry the app owns. Contain failures so one bad child does not blank the page. Render native SwiftUI. Dispatch taps as data. Flow-UI names those types. You can implement the same seams without the package. You should not skip a seam and hope a switch will grow into production.

This is the architecture post. The architecture doc is the map. Here is why the stages exist, in the order the bytes actually move.

Load: PageStore and the host seam#

PageStore is @Observable and @MainActor. It owns fetch lifecycle: initial load, pull to refresh, pagination, cancellation. Views observe state: loading, loaded(PageModel), failed(message:), empty. Nothing else in the framework talks to the network.

The host implements PageLoader:

APILoader.swiftswift
struct APILoader: PageLoader {
    func loadPage(_ request: PageRequest) async throws -> Data {
        try await client.data(for: request)
    }
}

PageRequest.Kind is initial, refresh, nextPage(postback:), or action. Postback is opaque. Echo it. The store retains the in-flight Task so a newer request can cancel an older one. A slow refresh must not overwrite a newer api mutation.

Refresh keeps current content visible and resets WidgetStateStore. Pagination failure does not blank a healthy page. Those are architecture choices, not niceties.

swift
let store = PageStore(pageID: "home", loader: loader, registry: registry)

Decode: FlowCore, lossy on purpose#

Hand-written Decodable with decodeIfPresent defaults keeps the contract additive. The walk is PageModel, then SectionModel, then AnyWidget. Layout, actions, and tracking ride beside data.

Arrays are LossyArray. One malformed button does not empty a button_row. Unknown section arrangements fall back to vertical. Diagnostics record type, coding path, and a readable message.

This stage is why old binaries survive new type strings. If decode throws the whole page, you do not have a server driven architecture. You have a fragile parser.

Resolve: WidgetRegistry#

Each widget type string is looked up in WidgetRegistry. register<V: WidgetView>(_ viewType: V.Type) stores a decode closure and an @MainActor view builder in the same generic scope. That is the only AnyView erasure point in the framework.

There is no framework enum of widgets to extend. Your app registers starters and product types at launch. Last write to entries[V.Content.widgetType] wins.

Unknown types follow UnknownWidgetPolicy: placeholder in debug, skip in release, unless you pass a policy. That is resolve-time containment, after decode has already kept the rest of the array.

Render: FlowPageView#

FlowPageView draws nav, sticky or scrolling header and footer bars, sections, sheets, and toasts. SectionRenderer honours vertical, carousel, and grid. Chrome from WidgetLayout applies in one fixed order: padding, width, background or gradient, corner clip, border, margin.

You do not reimplement a page shell per feature. You present the page view and let the envelope describe bars and sections.

Act: ActionDispatcher#

Taps carry ActionData. The type is a free string. The full JSON object is preserved so a handler can decode its own payload. Built-ins cover toast, dismiss, refresh_page, open_bottom_sheet, and api. Deeplinks are host handlers. The dispatcher is a chain of responsibility.

api responses can return PageMutation: replace the page, append or prepend sections, replace or remove a widget by id. Mutations are why stable widget ids are architecture, not cosmetics.

What this architecture refuses#

It refuses a closed Codable enum as the catalogue.

It refuses decoding that fails the page on one unknown child.

It refuses putting URLSession inside the renderer.

It refuses a second navigation framework fighting UIKit.

It refuses claiming that new widget types skip App Review. Types are Swift. Composition of known types is data.

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

How to explain it on a whiteboard#

Draw four named boxes in the order this article used them: PageStore, WidgetRegistry, FlowPageView, ActionDispatcher. The host owns PageLoader to the left of the store, and extra action handlers to the right of the dispatcher. The backend owns the JSON that flows through all four. If a proposal skips the registry (a giant switch in the view), or skips containment (throw on unknown), or merges the loader into the package (the renderer now has your auth headers), you are not looking at this architecture anymore.

Compared with gist tutorials#

Gists start at the enum and the switch. They never get to cancellation, pagination merge, duplicate ids, or nested widgets. Architecture is those unglamorous types. Read resilience after this post, then the iOS implement guide if you want the same seams as a checklist.

PageStore is not an app view model#

It is one page id. Another feature can have another store, or stay handwritten SwiftUI. FlowCore does not import SwiftUI, so schema tests and some server-side Swift tooling can share models. The renderer stays on the client.

Identities, mutations, and why they sit here#

FlowIdentity.positional builds prefix@codingPath when the backend omits id. Duplicate ids on the page become id#2 and duplicateID in diagnostics. Nested accordion children are not rewritten for page-wide uniqueness. replaceWidget and removeWidget walk header, section headers, section bodies, and footer. They do not open widget payloads. If you need to mutate a nested child, replace the parent widget instead.

Starter widgets register through FlowWidgets.register(on:): title_block, image_text_card, banner, button_row, tag_rail, stepper_row, accordion, separator. They are not privileged in the architecture. They are rows in the same table.

UIKit hosts use FlowHostingController(store:). The widgets are still SwiftUI. The architecture does not fork for UIKit.

What you still ship in the binary#

New WidgetView types. New ActionHandler types beyond toast, dismiss, refresh_page, open_bottom_sheet, and api. A ThemeProvider if you speak tokens. The loader. That is the honest split from Guideline 2.5.2: rearranging known widgets is data. Inventing a type is a release. This is not legal advice.

Server driven UI architecture on iOS is PageStore, then WidgetRegistry, then FlowPageView, then ActionDispatcher. Name the types. Keep the host seams. Contain failure by default. The architecture concept is the same map in fewer words.

UIKit does not get a second architecture. FlowHostingController hosts the same FlowPageView. Deeplinks remain host handlers. HTTP remains PageLoader. Unknown widgets remain a policy. The four types do not change because the container is UIViewController.

The four types as a launch checklist#

Create a registry. Register starters, then product widgets. Implement PageLoader for initial, refresh, nextPage(postback:), and action. Construct PageStore(pageID:loader:registry:). Optionally construct ActionDispatcher and register a deeplink handler. Present FlowPageView(store:dispatcher:) or FlowHostingController. That is launch. Everything else is JSON.

If any step is missing, you will rebuild it. A view that decodes JSON in body skips the store. A store that calls URLSession skips the loader seam. A switch in the page view skips the registry. Hardcoded NavigationLink trees skip the dispatcher. The architecture is the refusal of those shortcuts. The build tutorial is the same checklist in tutorial voice. When not to use SDUI still applies: this architecture is for feeds, merchandising, onboarding, and experiments, not for every page in the binary.

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