Skip to content
Ayhan Sipahi Ayhan Sipahi

Server-Driven UI for Native Mobile Apps

Server-Driven UI is the mobile analog of server-side composition. The hard part is not JSON rendering but a versioned component contract that survives old app versions.

You ship a native app, then need to change a merchandising screen, a promo banner, or a checkout step every week. Each change waits on app-store review and on users updating, and the app versions already installed stay in users’ pockets for months, so you cannot roll the client back the way you redeploy a server. Server-Driven UI (SDUI) answers this by shipping a UI description instead of new native code: the recommendation here is to treat it as a scalpel for content-shaped surfaces and design the contract so old clients degrade gracefully.

The Web Analog and Mobile’s Extra Constraint

On the web, server-side composition ships composed HTML, and a change is live the moment the browser fetches the page. Native mobile cannot do that. The app-store review gate sits between you and every binary, so you cannot push native code on demand, and a server change that assumes the newest client will break everyone who has not updated.

SDUI is the mobile analog of that web pattern. Instead of rendered markup or new native code, the server ships a component tree (a layout spec, usually JSON), and the already-installed native client renders it through a component registry. Airbnb’s Ghost Platform passes UI and data together over a single shared GraphQL schema for web, iOS, and Android. REI generates its filtering and sorting screens entirely from a flexible JSON schema returned by the backend.

The JSON-to-native rendering is the easy part. The hard problem is the versioned component contract, because the native client can only render components it already shipped, and those shipped versions live in the wild for months.

The Shape of an SDUI Response

The server emits a component tree. Each node names a registered component type, carries props or data, and optionally children. A sketch (illustrative, not a verbatim copy of any vendor schema):

{
  "screen": "home_promo",
  "version": 3,
  "components": [
    { "type": "Banner", "props": { "title": "Summer sale", "imageUrl": "https://cdn.example.com/s.jpg" } },
    { "type": "ProductCarousel", "props": { "items": [] } },
    {
      "type": "RichTextCard",
      "props": { "markdown": "**New** layout" },
      "fallback": { "type": "TextCard", "props": { "text": "New layout" } }
    }
  ]
}

Two things in this envelope carry the whole design. The top-level version lets the client reason about what the payload assumes. The per-node fallback lets the server hand an old client a component it definitely shipped when a newer one might be missing.

The Client Component Registry

The native client holds a registry: a type string mapped to a native view builder. It walks the tree, looks up each type, and builds native views. The critical line is what happens for a type the registry does not know. That is the backward-compatibility spine, so it cannot be an afterthought.

Here is the registry with a mandatory unknown-component fallback in SwiftUI. The same shape applies to a Jetpack Compose @Composable map or a React Native component map.

struct ComponentSpec: Decodable {
    let type: String
    let props: [String: JSONValue]
    let fallback: Box<ComponentSpec>?  // recursive, optional
}

@MainActor
final class ComponentRegistry {
    typealias Builder = (ComponentSpec) -> AnyView
    private var builders: [String: Builder] = [:]

    func register(_ type: String, _ builder: @escaping Builder) {
        builders[type] = builder
    }

    /// Resolve a spec to a view. Unknown types try their declared
    /// fallback, then render nothing rather than crashing.
    func view(for spec: ComponentSpec) -> AnyView {
        if let builder = builders[spec.type] {
            return builder(spec)
        }
        if let fallback = spec.fallback?.value {
            return view(for: fallback)  // recurse into the safe alternative
        }
        // No builder, no fallback: skip this node, keep the screen alive.
        return AnyView(EmptyView())
    }
}

The rule the registry encodes is simple: an unknown type never crashes and never blanks the whole screen. It renders its declared fallback, or it renders nothing and lets the rest of the tree through.

The Versioned Component Contract

This is where the thesis lives. The client can only render what it shipped, and store-distributed versions stay installed for months, so the contract has to assume version skew as the normal case, not the exception.

yes

no, has fallback

no, no fallback

Server composes spec

JSON tree over network

Client walks tree

type in registry?

Build native view

Render fallback

Skip node, keep screen

Rendered screen

There are two documented fallback strategies, and most teams use both. With client-side fallback, the registry has a default handler: an unknown type returns a generic placeholder or renders nothing. REI hardcodes UI treatments so the client stays forward-compatible with data the backend is not ready to supply yet. With server-side fallback, the response embeds an alternative (a RichTextCard carries a simpler TextCard). The server keeps a map of which app version each component is available from, and tailors the payload, so clients send a header advertising framework, app, and OS version on every request.

The version-compatibility surface is a matrix, not a line. Pairing a payload version against an installed client version gives three outcomes, and the contract exists to keep every cell out of the failure state.

Old client

New payload: renders with fallback

Old payload: renders fully

New client

New payload: renders fully

Old payload: renders fully

The rule to hand the reader is one sentence: props are additive-only; never repurpose or remove a field, version the component instead of mutating it, and every new component must declare a fallback or be additive-only. Apple’s Guideline 2.5.2 turns this into a data-not-logic discipline. Shipping a UI description the client reads is fine. Shipping a config the app evaluates as behavior drifts toward downloaded executable code, which the guideline does not allow. The line is acknowledged as subjective in practice, which is exactly why staying clearly on the declarative side protects you.

Spec Format Options

Teams differ sharply in how much they trust the wire. There are three declarative formats plus one boundary case that is no longer purely declarative.

A versioned JSON envelope is flexible, human-readable, and weakly typed. REI uses a flexible JSON schema; DoorDash’s Facets framework maps one Facet one-to-one to a view. A shared GraphQL schema standardizes the data model: Airbnb’s Ghost Platform serves all three platforms from one schema, and Apollo documents this as returning product and UI information from the API rather than domain data. A protobuf or typed IDL gives strong typing and codegen for both ends; MobileNativeFoundation contributors describe defining primitives like buttons and layouts in protobuf with native and web renderers.

The boundary case is pushing logic, not just data. Cash App’s Redwood, with Treehouse and Zipline, ships Kotlin/JS that executes on the client (with WebAssembly as their stated future direction) rather than a declarative tree. This sits much closer to the Guideline 2.5.2 line for downloaded executable code than declarative SDUI does. It is a powerful approach, but it is a different risk posture, so treat it as the edge of the design space rather than the default.

For historical context, Spotify’s HubFramework was one of the earliest at-scale component-driven UI systems on iOS. It is archived and deprecated now, so cite it as precedent, not as something to adopt.

Use SDUI as a scalpel for dynamic, content-shaped surfaces: merchandising, promos, forms, feature-flagged and A/B-tested layouts. Keep interaction-heavy, gesture- and animation-rich, latency-critical, and offline-first screens native-built. For the contract, default to a versioned JSON envelope with an explicit type registry and a mandatory unknown-component fallback rule, not a fully typed protobuf IDL.

The reason is the whole point of the pattern. Graceful degradation on old clients is the job, and loose-but-versioned JSON degrades more gracefully than a rigid schema that refuses to decode a field it has not seen. The trade-off is real: you give up compile-time safety and you take on the discipline of a fallback contract per component. You accept that in exchange for the one property the rigid schema cannot give you cheaply, which is surviving a payload from the future.

When to Override the Default

no

yes

yes

yes

no

no, reuse web content or a web team owns it

Screen needs to change without a release?

Native-built screen

Content-shaped, no new interaction patterns?

Gesture-rich, latency-critical, or offline-first?

SDUI: versioned envelope plus fallback

WebView fragment

The protobuf or typed IDL override applies to performance-critical, high-volume, tightly-coupled internal surfaces where both ends ship together often enough that version skew is bounded and codegen pays off. The WebView override applies when you genuinely need web content reuse rather than native feel.

That last case is worth a clear distinction, because teams often reach for SDUI and WebViews to solve the same dynamism problem. SDUI renders native views from a server spec; the client owns the rendering and the result feels native. A WebView micro-frontend embeds web content in a native shell; you get full web reuse and instant change but pay with non-native feel, bridge complexity, and a separate runtime. If the answer is genuinely web-shaped content owned by a web team, that is the override, and the WebView series covers its mechanics.

Common Pitfalls

The most common failure is no mandatory fallback rule, which leaves old clients rendering blanks. The fix is structural: the registry skips unknown types and the contract requires every component to declare a fallback or be additive-only.

A close second is breaking changes to existing component props, which makes old clients misrender. Treat props as additive-only, never repurpose or remove a field, and version the component rather than mutating it. Related is emitting capabilities by app-version guesswork, which drifts; instead, let the client advertise its registry or capability version on each request and let the server tailor the payload to declared capabilities.

Two more are about scope. Putting latency-critical or gesture-heavy screens in SDUI produces a janky, network-dependent feel; keep those native. And reinventing the browser is the slow one: an SDUI system grows conditionals, expressions, and styling until it is a worse, untested web engine. Keep the spec declarative and bounded, and escalate genuinely web-shaped needs to a WebView.

When you operate SDUI, watch the contract health rather than vanity counts. The signals worth tracking are fallback hit rate (high means contract drift), the share of installed client versions that can render the current payload, the blank-screen or render-failure rate by app version, and the ratio of SDUI screens to native screens (so the scalpel stays a scalpel). Time-to-change for a dynamic surface is the target the pattern is bought for; treat the move from release-gated days to server-deploy minutes as a goal to measure, not a number to assume.

Closing

SDUI is the right tool when a surface is content-shaped, changes often, and does not depend on rich interaction, low latency, or offline behavior, and when you can commit to a versioned envelope with a mandatory fallback per component. Reach for the protobuf override only on tightly-coupled internal surfaces where both ends ship together, and for a WebView only when you truly need web content reuse. The pattern earns its keep at the contract layer, so before you build the rendering engine, write down the additive-only props rule and the unknown-component fallback rule; that is the part that survives the old app versions you cannot roll back.

References

Related posts