---
title: "Server-Driven UI: Ship Mobile Changes Without App Review"
published: true
description: "Build a server-driven UI system with versioned JSON schema contracts, typed component registries in Jetpack Compose and SwiftUI, and a backend rendering pipeline."
tags: android, swift, architecture, mobile
canonical_url: https://blog.mvpfactory.co/server-driven-ui-ship-mobile-changes-without-app-review
---
## What We're Building
In this workshop, I'll walk you through the core pieces of a production Server-Driven UI (SDUI) system: a versioned JSON schema contract, typed component registries in Jetpack Compose and SwiftUI, and the backend rendering pipeline that maps business logic to UI trees. Get this right and you go from a two-week release cycle to sub-hour UI deployments.
The docs don't mention this, but the single biggest bottleneck in mobile iteration speed isn't engineering capacity — it's app store review latency. A copy change, a reordered card layout, a new promo banner: each requires a full binary release. Airbnb documented this exact pain when they built their Ghost Platform, and Lyft's engineering team reached similar conclusions with their own SDUI infrastructure.
## Prerequisites
- Familiarity with Jetpack Compose or SwiftUI
- A backend service capable of serving JSON (any language works)
- Understanding of component-based UI architecture
## Step 1: Define the JSON Schema Contract
Everything starts here. Not the renderers — the contract. Treat it with the same rigor you'd apply to a REST or gRPC endpoint.
json
{
"schema_version": "2.4",
"screen": {
"id": "home_feed",
"components": [
{
"type": "hero_card",
"version": 2,
"props": {
"title": "Welcome back",
"image_url": "https://cdn.example.com/hero.webp",
"cta": { "label": "Explore", "action": "navigate://discover" }
}
},
{
"type": "horizontal_list",
"version": 1,
"props": {
"items_ref": "trending_items",
"item_component": "product_tile"
}
}
]
}
}
Here is the minimal setup to get this working: `schema_version` is top-level so the client checks it before parsing. Each component carries `type` + `version` so the client knows which renderer to invoke. Actions use URI schemes (`navigate://`, `deeplink://`, `api://`) routed through a single dispatcher.
## Step 2: Build the Fallback Component First
Let me show you a pattern I use in every project. Before you register a single real component, implement and ship the fallback renderer. It's your safety net for every future schema evolution.
## Step 3: Wire Up Typed Component Registries
Most teams get this wrong by building a monolithic switch statement. Don't. Use a registry pattern.
### Jetpack Compose
kotlin
object ComponentRegistry {
private val renderers = mutableMapOf Unit>()
fun register(type: String, version: Int, renderer: @Composable (JsonObject) -> Unit) {
renderers[ComponentKey(type, version)] = renderer
}
@Composable
fun Render(type: String, version: Int, props: JsonObject) {
val renderer = renderers[ComponentKey(type, version)]
?: renderers[ComponentKey("fallback", 1)]
renderer?.invoke(props)
}
}
### SwiftUI
swift
class ComponentRegistry {
static let shared = ComponentRegistry()
private var renderers: [ComponentKey: (JSON) -> AnyView] = [:]
func register<V: View>(_ type: String, version: Int,
renderer: @escaping (JSON) -> V) {
renderers[ComponentKey(type, version)] = { json in AnyView(renderer(json)) }
}
func resolve(_ type: String, version: Int, props: JSON) -> AnyView {
let key = ComponentKey(type, version)
return renderers[key]?(props) ?? renderers[.fallback]!(props)
}
}
The `(type, version)` pair maps to a native renderer. Unknown versions degrade gracefully — an empty spacer, a minimal card, or nothing — rather than a crash.
## Step 4: The Backend Rendering Pipeline
The backend maps business logic to UI trees through a pipeline:
1. **Resolve user context** — location, segment, experiment cohort
2. **Fetch content** from CMS, product catalog, or feature flags
3. **Assemble component JSON** via a template engine or code-based builder
4. **Negotiate versions** — the client sends `max_schema_version` in a header; the backend downgrades or omits unsupported components
5. **Cache and serve** — CDN-cache keyed on user segment + schema version
This is where Airbnb's Ghost Platform approach pays off: the UI tree is a pure function of server state, making it trivially cacheable and testable. Lyft describes a similar separation — the rendering decision lives entirely on the server, and the client is a thin display layer.
| Client Schema Version | Server Has `hero_card` v3 | Response |
|---|---|---|
| 3.0+ | Sends v3 | Full v3 rendered |
| 2.0–2.9 | Downgrades to v2 | v2 with reduced props |
| < 2.0 | No compatible version | Omits or sends fallback |
## Gotchas
- **Skipping the fallback component.** Here's the gotcha that will save you hours: if an older app version encounters an unknown component without a fallback registered, it crashes. Ship the fallback before anything else.
- **Monolithic switch statements.** They grow unmanageable fast. The registry pattern keeps component renderers decoupled and testable.
- **Client-side version negotiation.** Keep backward compatibility logic on the server — where you can change it without a release — not buried in client-side parsing code.
- **Binary size creep.** Traditional native UI grows linearly with features. SDUI keeps it flat because components are generic. But only if you resist the urge to add one-off renderers for every design variation.
## Wrapping Up
Start with the schema contract, build the fallback renderer, then wire up the registry. The backend pipeline comes last because it needs the contract to build against. Follow this order and you'll have a working SDUI system that turns two-week release cycles into minutes. That's the kind of architectural win worth building for.
Top comments (0)