How to implement SDUI on iOS
Implement SDUI on iOS by decoding a page envelope, registering widgets, containing unknown types, and dispatching actions as data.
Implement SDUI on iOS by decoding a page envelope, registering widgets, containing unknown types, and dispatching actions as data. The SwiftUI tutorial in this series named the four seams. This post is the iOS-shaped version: UIKit hosting, iOS 17, Observation, and the decode path that must not crash a session.
Decode the envelope, not a private struct per page#
One PageModel type is the contract. Hand-written Decodable with decodeIfPresent defaults is how FlowCore stays additive. If every team invents HomePayload and CartPayload as unrelated structs, you will never share widgets or mutations.
The walk is page, then section, then AnyWidget. Layout, actions, and tracking ride beside data. Arrays are lossy: one bad child does not empty the section.
Pass that registry into PageStore. The store calls your PageLoader, decodes, and holds the page. A slower response does not overwrite a newer one.
Register widgets from the app target#
iOS 17+ is required. The renderer uses Observation and modern scroll APIs. There is no compatibility layer.
Widgets are @MainActor because View is. Register at launch, on the main actor, before the first FlowPageView appears.
UIKit apps do not rewrite first. FlowHostingController(store:dispatcher:) is a UIHostingController. Push it. The widgets are still SwiftUI.
Contain unknown types#
Old App Store binaries are a production fact. The backend will ship type strings this binary does not know.
UnknownWidgetPolicy defaults to .placeholder in debug and .skip in release. Diagnostics record type and coding path. Duplicate ids get a suffix and a report, because ForEach and mutations both break when two widgets share an id.
This is not optional polish. It is the difference between "SDUI crashed the home page" and "one promo is missing on last month's build".
Dispatch actions as data#
Taps are not NavigationLink trees hardcoded in the widget. The backend sends ActionData. ActionDispatcher is a chain. Built-ins: toast, dismiss, refresh, bottom sheet, api mutations.
Deeplinks are host handlers. Flow-UI ships no router on purpose. Your app already has tabs, a stack, or a coordinator. Forward the URL there.
API actions round-trip through PageLoader as PageRequest.Kind.action. The response can mutate the page (replace a widget, patch a section) without fetching a whole new envelope.
Networking stays in the host#
PageLoader.loadPage(_:) async throws -> Data is the whole HTTP story. Flow-UI performs no network calls. Image loading is the same idea: FlowImageLoader is a host seam.
That is how you keep URLSession configuration, certificates, and auth in the app that already owns them.
Observation and state#
PageStore is the page. WidgetStateStore is ephemeral per-widget state (stepper counts, expansion). SwiftUI Observation is the invalidation path. Do not duplicate page JSON into a second ObservableObject unless you enjoy desync.
Stable widget ids matter on refresh. Send real ids for anything interactive. Positional fallbacks exist so ForEach does not reshuffle, but they are the floor.
Cancellation and races#
PageStore retains the in-flight fetch so a newer request can cancel an older one. Without that, a slow refresh can overwrite a fresh api mutation. You do not have to reimplement this if you use the store. If you roll your own, you will rediscover it in production.
Pagination appends by re-reading current state after await, and it ignores a next-page response when the cursor no longer matches. That is how a replace_widget during a fetch does not get clobbered.
A worked iOS checklist#
| Step | Owner |
|---|---|
| Add the Swift package | App |
| Register starter + product widgets | App launch |
Implement PageLoader | Existing API client |
Present FlowPageView or FlowHostingController | Feature |
Register deeplink ActionHandler | Navigation |
Point telemetry at DecodeDiagnostics | Debug + production logs |
Questions this article should close#
UIKit or SwiftUI first?#
If the app is UIKit, host FlowHostingController and keep your coordinators. If the app is SwiftUI, present FlowPageView. Do not rewrite the app as a prerequisite.
What about iPad and Mac Catalyst?#
SwiftUI widgets run where SwiftUI runs. You still have to design layouts that make sense in a regular-width size class. The envelope can send different pages per size class from the backend if you send that signal in PageLoader. The package does not invent a breakpoint system.
Read next#
How to implement SDUI on iOS: decode, register, contain, dispatch. Networking stays yours. The router stays yours.
Related documentation
Get started
Quick start
Build your first server driven UI screen in SwiftUI: register widgets, implement a page loader, and render backend JSON as native iOS views.
Concepts
Architecture
How Flow-UI turns a JSON page into native SwiftUI: decode through an open registry, contain failures, render widgets, and dispatch actions as data.
Concepts
Resilience
One bad widget never blanks a screen. How Flow-UI decodes defensively and reports precisely.
Related articles
- Server-Driven UI4 min read
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.
- Server-Driven UI5 min read
SDUI vs WebViews on iOS
SDUI renders native SwiftUI from JSON. A WebView renders HTML in a browser process. Teams pick SDUI when the page should feel like the rest of the app.
- Server-Driven UI4 min read
API driven UI and remote UI, explained
API driven UI and remote UI are names for the same idea as server driven UI: the API returns page structure, the iOS app paints SwiftUI.
More on the blog.
Read the source
Flow-UI is MIT licensed. The schema, renderer and starter widgets live on GitHub.
GitHubGet started with Flow-UI
Install the Swift package, register a widget, and render a page from JSON.
Get started