Skip to content

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.

Ayhan Sipahi Ayhan Sipahi

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 versioned component contract decides whether SDUI works in production. 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:

{
  "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 and avoid a crash.
    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 registry’s job is to make sure an unknown type never crashes the app 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#

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.

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. 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 ship every new component with a fallback. 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, and the line between description and behavior is acknowledged as subjective in practice.

Spec Format Options#

Teams differ sharply in how much they trust the wire, and the format they pick reflects that.

A versioned JSON envelope is flexible and human-readable, but weakly typed; REI’s schema and DoorDash’s Facets framework, which maps one Facet to one view, both take this route. GraphQL sits at the other end of the type spectrum: Airbnb’s Ghost Platform standardizes on one shared schema across web, iOS, and Android, and Apollo documents this as the API returning product and UI information, separate from domain data. Protobuf or a typed IDL trades some of that flexibility for strong typing and codegen on both ends; MobileNativeFoundation contributors describe defining primitives like buttons and layouts in protobuf, with native and web renderers on each side.

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, in place of 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 with a different risk posture; treat it as an edge case beyond the default recommendation below.

For historical context, Spotify’s HubFramework was one of the earliest at-scale component-driven UI systems on iOS, though it is now archived and deprecated.

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; save the fully typed protobuf IDL for the override cases below.

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 take on the discipline of a fallback contract per component.

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 over 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.

Failure Modes and Signals to Watch#

The most common failure is skipping the fallback rule from the section above: an old client meets an unknown type with nothing to fall back to, and the screen renders blanks instead of content. The registry code already handles this mechanically; the discipline is applying the rule to every new component, including the ones that do not look risky.

A close second shows up during code review: a prop gets repurposed or removed instead of added, and the old clients that still read the original field misrender. A quieter version of the same drift comes from guessing client capabilities off the app-version number; let the client advertise its registry or capability version on each request, and tailor the payload to what it declares.

Two more failures live outside the contract, in scope decisions. Gesture-heavy or latency-critical screens turn janky and network-dependent when routed through SDUI, so keep them native. And a spec that keeps absorbing conditionals, expressions, and styling options is a sign to stop adding flexibility to the JSON and route the next request to a WebView.

When you operate SDUI, watch contract health. 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. Time-to-change for a dynamic surface is the metric the pattern is bought for: how long a content change takes to go live without a release.

Closing#

The default holds for content-shaped surfaces that change often without needing rich interaction, low latency, or offline support. Override it only for the cases above: protobuf when both ends ship together tightly enough to justify the codegen, WebView when the content is genuinely web-owned.

References#

Related posts