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 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.
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.
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.
Recommended Default#
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#
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#
- A Deep Dive into Airbnb’s Server-Driven UI System (Ghost Platform) (opens in new tab) - Canonical reference: a shared GraphQL schema across web/iOS/Android, with UI and data passed together
- The Journey to Server Driven UI At Lyft Bikes and Scooters (opens in new tab) - Why a team adopts SDUI: business complexity, release velocity, and staffing flexibility
- Improving Development Velocity with Generic, Server-Driven UI Components (DoorDash, Facets) (opens in new tab) - A layout engine plus component library where one Facet maps one-to-one to a view
- Server Driven UI: Prepared for the Unknown (REI Co-op Engineering) (opens in new tab) - Strong source on forward compatibility: a flexible JSON schema with the client forward-compatible with data the backend is not ready to send
- Spotify HubFramework (deprecated) (opens in new tab) - An early at-scale component-driven iOS UI framework, now archived; cited as historical precedent only
- Server-driven UI strategies (MobileNativeFoundation Discussion #47) (opens in new tab) - The protobuf-primitives approach plus offline and animation limitations of SDUI
- Native UI and multiplatform Compose with Redwood (Cash App Code Blog) (opens in new tab) - The “push logic, not just data” boundary case using Zipline and Kotlin/JS plus WebAssembly
- How to Safely Release Server-Driven UI Updates at Scale (Digia) (opens in new tab) - Client and server fallback strategies, version-advertising headers, and component-availability mapping
- Server-Driven UI Basics (Apollo GraphQL Docs) (opens in new tab) - The GraphQL-schema framing of the contract: return UI and product info, not domain data
- App Review Guidelines (Apple Developer, Guideline 2.5.2) (opens in new tab) - The constraint that both enables and bounds SDUI: data descriptions are fine, downloaded executable code is not
- Fixing Section 2.5.2 (Saagar Jha) (opens in new tab) - Why the data-versus-logic line is subjective in practice
- Shipping Mobile When You Can’t Roll Back the Client - The motivating constraint this pattern answers
- Server-Side Micro-Frontend Composition - The web counterpart of this idea
- Mobile Micro-Frontends with React Native and Expo WebViews - The WebView alternative, contrasted
Related posts
A mobile binary can't be rolled back and old versions linger, so safety and speed move server-side: a BFF, consumer-driven contracts, and backward-compatible versioning.
mobile · api-design · testing +1
A pragmatic guide for designers working with async backends: three interaction patterns, when to use each, and four anti-patterns to push back against.
event-driven · state-management · design-patterns +2
A practical comparison of headless CMS options (Strapi, Contentful, Kontent, Storyblok) with Cloudinary image management and framework integration.
typescript · nextjs · react-native +3
Patterns for shipping micro frontends across mobile, web, and desktop: performance, offline support, and production insights, with Rspack and Re.Pack approaches.
expo · performance · re-pack +4
How to implement micro frontend architecture in mobile apps with React Native Expo and WebViews, covering performance, proven patterns, and bundlers.
expo · mobile · re-pack +2