Server-Driven UI for Android: A Complete Implementation Guide
Learn how to build a flexible, server-driven UI system for Android using Jetpack Compose. This guide covers the architecture, code patterns, and best practices used by companies like Airbnb and Netflix.
1. Why Server-Driven UI?
Traditional Android development means every UI change requires a new APK release, Play Store review, and user adoption. Server-Driven UI (SDUI) flips this: the server defines what the UI looks like, and the client renders it dynamically.
This enables:
- Instant updates — Change UI without app store review
- A/B testing — Test different layouts without new builds
- Personalization — Show different UIs to different users
- Legacy user updates — Update users on old app versions
2. SDUI Architecture Overview
A typical SDUI system has three layers:
- Server — Generates JSON describing the UI
- SDK — Parses JSON and renders native components
- Component Library — Your design system components
// Server Response
{
"type": "Column",
"children": [
{ "type": "Text", "props": { "text": "Hello World" } },
{ "type": "Button", "props": { "text": "Click Me" } }
]
}
// Client renders this as native Compose UI
3. Defining the JSON Schema
Your JSON schema is the contract between server and client. A node typically includes:
type— Component name (e.g., "Button", "Card")props— Configuration valueschildren— Nested componentsactions— Event handlers
@Serializable
data class PyramidNode(
val type: String,
val props: JsonObject = buildJsonObject {},
val children: List<PyramidNode> = emptyList(),
val actions: Map<String, PyramidAction> = emptyMap()
)
@Serializable
sealed class PyramidAction {
@Serializable
data class Navigate(val route: String) : PyramidAction()
@Serializable
data class SetState(val key: String, val value: JsonElement) : PyramidAction()
}
4. Building the Component Registry
The component registry maps type names to Composable renderers. This is where you connect JSON to your design system.
class ComponentRegistry {
private val components = mutableMapOf<String, ComponentRenderer>()
fun register(type: String, renderer: ComponentRenderer) {
components[type] = renderer
}
fun get(type: String): ComponentRenderer? = components[type]
}
typealias ComponentRenderer = @Composable (
node: PyramidNode,
context: RenderContext
) -> Unit
Register your components:
fun ComponentRegistry.registerBuiltIns() {
// Layout
register("Column") { node, ctx ->
Column { RenderChildren(node.children, ctx) }
}
register("Row") { node, ctx ->
Row { RenderChildren(node.children, ctx) }
}
// Content
register("Text") { node, _ ->
Text(text = node.props["text"]?.jsonPrimitive?.content ?: "")
}
// Interactive
register("Button") { node, ctx ->
Button(onClick = { ctx.executeAction(node.actions["onTap"]) }) {
Text(node.props["text"]?.jsonPrimitive?.content ?: "Button")
}
}
}
5. The Renderer
The renderer recursively walks the JSON tree and calls the appropriate component for each node.
@Composable
fun PyramidRenderer(
node: PyramidNode,
registry: ComponentRegistry,
state: PyramidState
) {
val context = remember(state) {
RenderContext(registry, state)
}
val renderer = registry.get(node.type)
if (renderer != null) {
renderer(node, context)
} else {
// Fallback for unknown types
Text("Unknown: ${node.type}", color = Color.Red)
}
}
@Composable
fun RenderChildren(
children: List<PyramidNode>,
context: RenderContext
) {
children.forEach { child ->
PyramidRenderer(child, context.registry, context.state)
}
}
6. State Management
SDUI needs to handle local state for interactive components. Use a state holder that maps keys to values:
class PyramidState {
private val _values = mutableStateMapOf<String, Any?>()
fun get<T>(key: String, default: T): T {
return _values[key] as? T ?: default
}
fun set(key: String, value: Any?) {
_values[key] = value
}
}
// Usage in a TextField component
register("TextField") { node, ctx ->
val key = node.props["stateKey"]?.content ?: "text"
var value by remember {
mutableStateOf(ctx.state.get(key, ""))
}
OutlinedTextField(
value = value,
onValueChange = {
value = it
ctx.state.set(key, it)
},
label = { Text(node.props["label"]?.content ?: "") }
)
}
7. Handling Actions
Actions connect UI events to behavior. Common patterns include navigation, API calls, and state updates:
class ActionHandler(
private val navController: NavController,
private val state: PyramidState
) {
fun execute(action: PyramidAction?) {
when (action) {
is PyramidAction.Navigate -> {
navController.navigate(action.route)
}
is PyramidAction.SetState -> {
state.set(action.key, action.value)
}
is PyramidAction.ApiCall -> {
// Trigger API request
}
null -> { /* no-op */ }
}
}
}
8. Production Considerations
Before shipping to production, consider:
Caching
Cache UI definitions locally to enable offline support and reduce API calls.
Versioning
Version your schema so old clients gracefully handle new component types.
Error Boundaries
Wrap rendering in try-catch to prevent crashes from malformed JSON.
Performance
Use key parameters in ForEach loops and memoize expensive computations.
Skip the complexity. Ship faster with Pyramid.
Building SDUI from scratch takes months. Pyramid gives you a production-ready SDK with 53+ components, visual editor, and A/B testing built-in.
Get Early Access →Conclusion
Server-Driven UI transforms how mobile teams ship. Instead of weeks of releases and reviews, you can update UI instantly. The architecture is straightforward: define a JSON schema, build a component registry, and render recursively.
While you can build this yourself (many companies do), tools like Pyramid can save months of development time and give you production-ready features out of the box.