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.
The rendering engine walks sections, resolves each widget type in the registry, and lays out native SwiftUI. Unknown types drop out. That sentence is the whole engine. The rest of this article is what "walk", "resolve", and "drop out" mean in Flow-UI, so you do not picture a mysterious runtime.
If you want the five-stage architecture (load through actions), read server driven UI architecture. This post stays inside the render pass: FlowPageView down to SectionRenderer and WidgetRowView.
What the engine is given#
A decoded PageModel. A WidgetRegistry. Theme environment. An ActionDispatcher and a WidgetStateStore. It is not given a URL. It is not given HTML.
FlowPageView switches on PageStore.state. Loading shows your loading view (or a default). Failed shows retry. Empty shows empty. Loaded draws nav, optional sticky bars, and a ScrollView of sections.
The page view installs environment objects: registry, state store, dispatcher, action relay. Sheets bind to FlowPresenter.activeSheet. Toasts overlay at the bottom. That is chrome around the walk, not the walk itself.
Walking sections#
SectionRenderer takes one SectionModel. Arrangement is vertical, carousel, or grid. Unknown arrangement falls back to vertical during decode, so the renderer can assume a known case.
Vertical is a VStack of WidgetRowView. Carousel is a horizontal snappable stack. Grid uses a column count from section layout. Item spacing and insets come from the section, not from each widget (widgets still have their own layout chrome).
An optional section header widget pins while the section body scrolls, using the parent LazyVStack pinned views. That is how a "Popular this week" title can stick without the backend inventing a new native controller.
Resolving a widget#
WidgetRowView (the row wrapper around a single AnyWidget) asks the registry for a view. The registry looks up Content.widgetType. If there is an entry, it decodes data into that WidgetContent and calls the stored makeView closure with WidgetContext.
If there is no entry, policy decides:
.placeholder: labelled warning block (debug default).skip: render nothing (release default)
The page continues. Diagnostics already recorded the miss at decode/resolve time.
That drop-out is the difference between an engine and a gist. A gist switch with no default does not compile, so people add default: EmptyView() and ship silence without a key path. The engine records the path.
Layout as a modifier, not as widget code#
After the widget body exists, WidgetLayoutModifier applies JSON chrome in a fixed inside-out order: padding, width, background and gradient, corner clip, border, margin. Widgets must not pad themselves to imitate chrome. If they do, every page double-pads and nobody knows which layer to fix.
Width fill, hug, or a fraction matters most in carousels (peeking neighbours). Fractions are data. The engine applies them uniformly.
Nested walk#
Accordion (and any container widget you write) holds child widgets. Those children decode through the same registry. The engine is recursive at the widget payload layer, not only at the section layer. Nested ids do not have to be unique page-wide; the parent iterates them. Page-level uniqueness applies to widgets the page ForEachs directly.
What the engine does not do#
It does not fetch. PageStore already did.
It does not interpret scripts.
It does not apply CSS.
It does not own image caching. FlowImageLoader is host code.
It does not pick a navigation stack. Actions leave the engine through the dispatcher.
Performance, without fake numbers#
Cost is decode plus view construction plus image loads you requested. A thousand widgets in one envelope will hurt in SwiftUI the same way a thousand handwritten views would. Paginate with has_more and postback. Split pages. Do not prove the engine scales by dumping the whole catalogue onto home.
Skeletons: some widgets provide WidgetSkeletonProviding. Loading states can look like the real shape. That is optional per type, not a second engine. The registry stores a skeleton thunk at register time when the type conforms.
The engine is not a virtual machine#
It is SwiftUI plus a lookup table. There is no bytecode. There is no CSS engine. Unknown types drop out. That is the whole trick, and it is enough.
Replacing FlowPageView#
You could walk page.sections yourself. You would reimplement bars, sheets, toasts, environment plumbing, pull to refresh, and the pagination sentinel. That is allowed. It is rarely worth it. UIKit apps should wrap the existing page view in FlowHostingController instead of rewriting the walk.
How SectionRenderer actually switches#
SectionRenderer is a real type in FlowRender. Vertical uses a VStack and ForEach over widgets. Grid uses LazyVGrid with columns (minimum 1, default 2). Carousel uses a horizontal ScrollView, LazyHStack, scrollTargetLayout(), and .viewAligned snapping. Widget width, including fractions that peek at the next item, is applied by widgetLayout for every arrangement. The carousel no longer has a private sizing modifier.
Section insets wrap the arranged content. Item spacing comes from layout.itemSpacing. A missing arrangement decodes as vertical, so the renderer's switch does not need an unknown case.
WidgetRowView after the section#
Unknown and malformed content never call registry.view. Debug policy paints a labelled warning. Release policy paints nothing. Registered content gets layout, envelope gestures, impression reporting, and an optional layout inspector overlay. Gestures attach only when the backend sent tap or long_press. VoiceOver gets a combined element and a button trait when the row is tappable. That is native, not a web accessibility tree.
Starter types and host types take this same row wrapper. title_block is not special-cased in SectionRenderer.
Diagnostics the engine relies on#
DecodeDiagnostics.Kind is unknownType, malformedPayload, droppedElement, and duplicateID. The engine does not re-decode to discover those. PageStore already collected them. A debug overlay can list coding paths while the visible tree has already skipped the bad rows.
Stable ids keep ForEach honest across refresh. Backend id, else prefix@codingPath, else a rename to id#2. Nested accordion ids stay inside the parent payload.
Read architecture for the pipeline around this walk, and pages, sections and widgets for the document the engine consumes. The architecture article names the four types this engine sits inside.
A server driven rendering engine walks sections, resolves types, applies layout, and drops unknown widgets. Keep that walk boring and the JSON can be interesting.
Containment during the walk, not after a crash#
SectionRenderer will happily ForEach a list that already lost children. Decode already ran LossyArray. The engine's job is not to retry JSON. Its job is to not explode when registry.view returns nil, when content is UnknownWidgetContent, or when arrangement was coerced to vertical. Debug overlays can still list DecodeDiagnostics entries. The visible tree can be missing rows. That is success.
Pull to refresh and pagination sentinels are part of FlowPageView, not of SectionRenderer. Do not reimplement them in a widget. The engine already owns the scroll chrome. Widgets own their body. Layout owns the chrome around the body. Keep those layers so the walk stays boring.
Related documentation
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
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.
Related articles
- Architecture6 min read
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.
- Architecture4 min read
When not to use server driven UI
Skip SDUI for maps, heavy gestures, latency-critical canvases, and apps that change UI once a quarter. Use it for feeds, merchandising, onboarding, and experiments.
- Production3 min read
App Store Guideline 2.5.2 and declarative JSON
A JSON page that picks among widgets already in the binary is data. Downloaded executable code is not. Keep the payload declarative.
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