JSON to SwiftUI: how a payload becomes a view
JSON becomes SwiftUI when the registry maps a type string to a WidgetView and the renderer emits that view for each widget in the page.
JSON becomes SwiftUI when three things are true at once: the document is a page of sections of widgets, a registry maps each type string to a WidgetView, and a renderer emits that view in order. There is no hidden compiler step. There is no downloaded template language. Bytes in, native views out.
This is the how-to companion to the page, section, widget model and the rendering engine walk. Medium tutorials titled "JSON to SwiftUI" usually stop at JSONDecoder plus a switch. That maps a closed list. This path maps an open table, which is why unknown types do not take the page down.
The payload is a page document#
The root is PageResponse. Inside it, PageModel lists sections. Each SectionModel lists AnyWidget values. Each widget has type plus data. Layout, actions, and tracking ride beside data so every type shares chrome and taps.
Decode is handwritten and lossy#
FlowCore uses Decodable with decodeIfPresent defaults. Arrays of sections and widgets are LossyArray. One bad element is droppedElement in DecodeDiagnostics. An unknown type becomes UnknownWidgetContent and unknownType. A registered type with a bad data object becomes MalformedWidgetContent and malformedPayload. The page decode does not throw for those content problems. It throws for a document that is not JSON, or that cannot satisfy the envelope at all.
WidgetRegistry participates as WidgetDecoding in decoder.userInfo. FlowCore does not import SwiftUI. You can unit test this step with fixture Data on a Mac. The build tutorial already warned: do not decode twice in the loader and again in the store. The store owns diagnostics.
The registry turns a string into a view type#
WidgetView is @MainActor. It requires init(content:context:). Content conforms to WidgetContent and publishes static let widgetType. Registering is one generic call. Last write for that string wins.
What register actually stores#
register captures a decode closure that reads V.Content from the data key, and a makeView closure that casts and wraps V(content:context:) in AnyView. That is the only erasure point. Your widget body stays a normal View. Optional WidgetSkeletonProviding stores a skeleton thunk for loading states.
Without a registry, JSON to SwiftUI means a function you maintain: switch type { case "title_block": ... }. Every new type edits that function and the enum. With a registry, a new type is a model, a view, and register. The adding a widget guide is those three steps.
FlowWidgets.register(on:) is the same mechanism for title_block, image_text_card, banner, button_row, tag_rail, stepper_row, accordion, and separator. Call it first if you want starters, then register product types, then override a starter by registering again.
The renderer emits the view#
PageStore is @Observable. When state is .loaded, FlowPageView walks sections. SectionRenderer arranges widgets. WidgetRowView asks registry.view(for:context:). Success applies widgetLayout, then envelope gestures.
Context is how JSON becomes an interaction#
WidgetContext carries widgetID, actions, state (WidgetStateStore), and dispatch. dispatch(event:) looks up the backend's action for "tap" or "change". dispatch(_ action:) fires an action embedded in payload buttons. The dispatcher handles toast, dismiss, refresh_page, open_bottom_sheet, and api. Deeplinks are host handlers. JSON described the tap. SwiftUI still performs it.
Layout JSON becomes modifiers in a fixed order: padding, width, background or gradient, corner clip, border, margin. If you also pad inside the widget, every page looks double-spaced and nobody knows which layer to fix. JSON to SwiftUI includes knowing which layer owns chrome.
Unknown types drop at this step in release (.skip). Debug shows a placeholder. The conversion is allowed to be lossy. A perfect mirror of the JSON tree is not the goal. A usable native page is.
A worked path in order#
- Host
PageLoaderreturnsDataforPageRequest(initial, refresh, next page, or action). Flow-UI does not callURLSession. PageStoredecodes with the registry, then setsloaded,empty, orfailed.FlowPageView(orFlowHostingControllerin UIKit) observes the store.- Each widget type string hits the registry.
- Each
WidgetViewbody runs on the main actor, like any other SwiftUI view.
Skip step 1 and you have previews only. Skip step 4 and you have a closed switch. Skip containment in step 2 and one unknown type fails the session.
What JSON cannot become#
JSON cannot become a widget type you did not compile. The server rearranges known types. A new gesture, a new control, a new WidgetView waits on a binary. That is the honest split from Guideline 2.5.2: declarative selection among shipped views is data. A script field that evals is not this pipeline.
JSON cannot become a navigation stack. There is no router in the package. A deeplink action is data for your handler.
JSON cannot become HTTP. The loader is yours. Caching, auth, and retries are yours.
Identities so SwiftUI can diff#
ForEach needs stable id values. Backend should send them for interactive or replaceable widgets. Otherwise decode uses FlowIdentity.positional. Collisions become id#2. Refresh then diffs instead of rebuilding the whole list and dropping scroll position. Nested accordion ids stay local to the parent. If you reuse faq_1 inside two accordions, that is fine for page-wide uniqueness. It is not fine if you expected replaceWidget to find a nested child. Mutations walk header, section headers, section bodies, and footer, not payloads inside widgets.
Decode diagnostics are part of the conversion#
unknownType, malformedPayload, droppedElement, duplicateID. JSON to SwiftUI is allowed to drop children. A perfect mirror of the JSON tree is not the goal. A usable native page is. Debug policy shows a placeholder. Release skips.
WidgetView is @MainActor. init(content:context:) is the only required initializer. WidgetContext.dispatch is how a tap leaves the view. The registry remains the only AnyView erasure point. Do not wrap your body in AnyView yourself.
Read pages, sections, and widgets and adding a widget. Earlier cluster posts: architecture, rendering engine, mental model, build tutorial.
JSON to SwiftUI is a pipeline with named seams, not a magic decoder that emits arbitrary interfaces. Map strings to WidgetView. Walk the page. Drop what you cannot paint. Keep the rest native.
What a first payload should look like#
One section, one title_block, maybe one image_text_card. Register starters. Load bytes from a fixture PageLoader. Wrap FlowPageView. If that does not paint, the pipeline is broken and thirty types will not fix it. Then add one product WidgetView. Then turn on api actions. Then pagination. Inverting that order is how JSON-to-view projects drown.
Do not decode in the loader and again in the store. Do not wrap every widget in AnyView in your own code. Do not pad in the view. Do not fetch in the view. The conversion is boring when the seams stay named. That is the goal.
Related documentation
Concepts
Pages, sections and widgets
The JSON envelope every Flow-UI response follows, from the page down to a single widget, and how it maps onto native SwiftUI views.
Guides
Adding a widget
Teach Flow-UI a new server driven UI component in three steps, entirely from your own app: model the payload, write the SwiftUI view, register it.
Related articles
- SwiftUI3 min read
What native means in an SDUI stack
Native SDUI means the widget is a real SwiftUI view: scrolling, gestures, and accessibility come from the platform, not from HTML.
- Schema6 min read
Page, section, widget: the mental model
An SDUI page is sections of widgets. That envelope is the whole contract. Screens and cards are not types in Flow-UI.
- Architecture6 min read
How a server driven rendering engine works
The rendering engine walks sections, resolves each widget type in the registry, and lays out native SwiftUI. Unknown types drop out.
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