Server-Driven UI Tutorial: Building Dynamic Screens with Jetpack Compose
Server-Driven UI (SDUI) lets you update your Android app's interface without publishing a new version to the Play Store. In this hands-on tutorial, we'll build a working SDUI system using Jetpack Compose.
By the end, you'll have a flexible architecture that can render any screen defined by your backend — ready for A/B tests, instant updates, and rapid iteration.
What You'll Learn
Understanding the Architecture
A server-driven UI system has four core pieces:
- Schema: A contract defining what components exist and their properties
- Server: Sends JSON describing which components to render
- Registry: Maps component types to Composable implementations
- Renderer: Transforms server JSON into actual UI
The beauty of this approach: your app ships with all possible components pre-built, but the arrangement and content comes from the server. Change the server response, change the UI — no app update required.
Key Insight: Think of SDUI like HTML. The browser (your app) knows how to render <div>, <button>, <img>. The server sends the document structure. Same principle, native performance.
Defining Your Component Schema
First, define a sealed class hierarchy representing your UI components. This gives you type safety and exhaustive pattern matching.
sealed class UIComponent {
abstract val id: String
// Layout Components
data class Column(
override val id: String,
val children: List<UIComponent>,
val spacing: Int = 8,
val padding: Int = 0
) : UIComponent()
data class Row(
override val id: String,
val children: List<UIComponent>,
val spacing: Int = 8,
val horizontalArrangement: String = "start"
) : UIComponent()
// Content Components
data class Text(
override val id: String,
val content: String,
val style: TextStyle = TextStyle.Body
) : UIComponent()
data class Image(
override val id: String,
val url: String,
val contentDescription: String?,
val width: Int? = null,
val height: Int? = null
) : UIComponent()
// Interactive Components
data class Button(
override val id: String,
val label: String,
val action: UIAction,
val style: ButtonStyle = ButtonStyle.Primary
) : UIComponent()
data class TextField(
override val id: String,
val placeholder: String,
val stateKey: String,
val validation: ValidationRule? = null
) : UIComponent()
}
enum class TextStyle { Headline, Title, Body, Caption }
enum class ButtonStyle { Primary, Secondary, Text }
Defining Actions
Actions describe what happens when users interact with components. Define them as another sealed class:
sealed class UIAction {
data class Navigate(
val destination: String,
val params: Map<String, String> = emptyMap()
) : UIAction()
data class ApiCall(
val endpoint: String,
val method: String = "POST",
val bodyFromState: List<String> = emptyList()
) : UIAction()
data class UpdateState(
val key: String,
val value: Any
) : UIAction()
data class ShowSnackbar(
val message: String
) : UIAction()
data class Sequence(
val actions: List<UIAction>
) : UIAction()
}
Building the Component Registry
The registry maps your schema types to actual Composable implementations. This is where your existing design system connects to SDUI:
class ComponentRegistry {
private val renderers = mutableMapOf<
KClass<out UIComponent>,
@Composable (UIComponent, ActionHandler) -> Unit
>()
init {
// Register built-in components
register(UIComponent.Column::class) { component, handler ->
SDUIColumn(component as UIComponent.Column, handler)
}
register(UIComponent.Row::class) { component, handler ->
SDUIRow(component as UIComponent.Row, handler)
}
register(UIComponent.Text::class) { component, _ ->
SDUIText(component as UIComponent.Text)
}
register(UIComponent.Button::class) { component, handler ->
SDUIButton(component as UIComponent.Button, handler)
}
// ... more registrations
}
fun <T : UIComponent> register(
type: KClass<T>,
renderer: @Composable (UIComponent, ActionHandler) -> Unit
) {
renderers[type] = renderer
}
@Composable
fun render(component: UIComponent, handler: ActionHandler) {
val renderer = renderers[component::class]
?: throw IllegalArgumentException("No renderer for ${component::class}")
renderer(component, handler)
}
}
Implementing Component Composables
@Composable
fun SDUIColumn(
component: UIComponent.Column,
handler: ActionHandler
) {
Column(
modifier = Modifier.padding(component.padding.dp),
verticalArrangement = Arrangement.spacedBy(component.spacing.dp)
) {
component.children.forEach { child ->
LocalRegistry.current.render(child, handler)
}
}
}
@Composable
fun SDUIText(component: UIComponent.Text) {
val style = when (component.style) {
TextStyle.Headline -> MaterialTheme.typography.headlineMedium
TextStyle.Title -> MaterialTheme.typography.titleLarge
TextStyle.Body -> MaterialTheme.typography.bodyLarge
TextStyle.Caption -> MaterialTheme.typography.bodySmall
}
Text(text = component.content, style = style)
}
@Composable
fun SDUIButton(
component: UIComponent.Button,
handler: ActionHandler
) {
when (component.style) {
ButtonStyle.Primary -> Button(
onClick = { handler.handle(component.action) }
) { Text(component.label) }
ButtonStyle.Secondary -> OutlinedButton(
onClick = { handler.handle(component.action) }
) { Text(component.label) }
ButtonStyle.Text -> TextButton(
onClick = { handler.handle(component.action) }
) { Text(component.label) }
}
}
Creating the Rendering Engine
The renderer ties everything together. It uses CompositionLocalProvider to make the registry available throughout the tree:
val LocalRegistry = compositionLocalOf<ComponentRegistry> {
error("No registry provided")
}
@Composable
fun SDUIScreen(
screen: ScreenDefinition,
registry: ComponentRegistry,
onAction: (UIAction) -> Unit
) {
val handler = remember(onAction) { ActionHandler(onAction) }
CompositionLocalProvider(LocalRegistry provides registry) {
Box(modifier = Modifier.fillMaxSize()) {
registry.render(screen.root, handler)
}
}
}
data class ScreenDefinition(
val id: String,
val root: UIComponent,
val initialState: Map<String, Any> = emptyMap()
)
class ActionHandler(private val onAction: (UIAction) -> Unit) {
fun handle(action: UIAction) = onAction(action)
}
Setting Up Networking
Now let's fetch screen definitions from your server. The JSON structure mirrors your sealed classes:
{
"id": "welcome-screen",
"root": {
"type": "column",
"id": "main-column",
"spacing": 16,
"padding": 24,
"children": [
{
"type": "text",
"id": "headline",
"content": "Welcome back!",
"style": "headline"
},
{
"type": "button",
"id": "cta",
"label": "Get Started",
"action": {
"type": "navigate",
"destination": "dashboard"
}
}
]
}
}
Use kotlinx.serialization with polymorphic serialization to parse this:
val json = Json {
ignoreUnknownKeys = true
classDiscriminator = "type"
serializersModule = SerializersModule {
polymorphic(UIComponent::class) {
subclass(UIComponent.Column::class)
subclass(UIComponent.Row::class)
subclass(UIComponent.Text::class)
subclass(UIComponent.Button::class)
// ... add all types
}
polymorphic(UIAction::class) {
subclass(UIAction.Navigate::class)
subclass(UIAction.ApiCall::class)
// ...
}
}
}
class ScreenRepository(private val api: SDUIApi) {
suspend fun getScreen(screenId: String): ScreenDefinition {
val response = api.fetchScreen(screenId)
return json.decodeFromString(response)
}
}
Handling Actions and Events
When users tap buttons or interact with components, you need to execute the actions defined by the server:
class ActionProcessor(
private val navigator: Navigator,
private val api: ApiClient,
private val stateManager: StateManager,
private val snackbarHost: SnackbarHostState
) {
suspend fun process(action: UIAction) {
when (action) {
is UIAction.Navigate -> {
navigator.navigate(action.destination, action.params)
}
is UIAction.ApiCall -> {
val body = action.bodyFromState
.associateWith { stateManager.get(it) }
api.call(action.endpoint, action.method, body)
}
is UIAction.UpdateState -> {
stateManager.set(action.key, action.value)
}
is UIAction.ShowSnackbar -> {
snackbarHost.showSnackbar(action.message)
}
is UIAction.Sequence -> {
action.actions.forEach { process(it) }
}
}
}
}
Managing State
SDUI screens often need local state — form inputs, toggles, selections. Use a central state manager keyed by string identifiers:
class SDUIStateManager {
private val _state = mutableStateMapOf<String, Any>()
val state: Map<String, Any> get() = _state
fun initialize(initial: Map<String, Any>) {
_state.clear()
_state.putAll(initial)
}
fun get(key: String): Any? = _state[key]
fun set(key: String, value: Any) {
_state[key] = value
}
@Composable
fun <T> observe(key: String, default: T): State<T> {
return remember(key) {
derivedStateOf { (_state[key] as? T) ?: default }
}
}
}
// In your TextField component:
@Composable
fun SDUITextField(
component: UIComponent.TextField,
stateManager: SDUIStateManager
) {
val value by stateManager.observe(component.stateKey, "")
OutlinedTextField(
value = value,
onValueChange = { stateManager.set(component.stateKey, it) },
placeholder = { Text(component.placeholder) }
)
}
Advanced Patterns
Caching for Offline Support
SDUI adds a server dependency. Mitigate this with aggressive caching:
- Memory cache: Keep recently fetched screens in memory
- Disk cache: Persist to Room or DataStore for offline access
- Fallback screens: Bundle critical screens in the APK as fallbacks
- Stale-while-revalidate: Show cached UI immediately, update in background
Conditional Rendering (A/B Tests)
Add a Conditional component that renders different children based on feature flags:
data class Conditional(
override val id: String,
val condition: String, // flag name or expression
val whenTrue: UIComponent,
val whenFalse: UIComponent? = null
) : UIComponent()
@Composable
fun SDUIConditional(
component: UIComponent.Conditional,
featureFlags: FeatureFlags,
handler: ActionHandler
) {
val shouldShow = featureFlags.evaluate(component.condition)
if (shouldShow) {
LocalRegistry.current.render(component.whenTrue, handler)
} else {
component.whenFalse?.let {
LocalRegistry.current.render(it, handler)
}
}
}
Custom Components
The registry pattern makes it easy to add custom components. Just define them in your sealed class and register the Composable:
// Add to UIComponent sealed class
data class ProductCard(
override val id: String,
val productId: String,
val title: String,
val price: String,
val imageUrl: String,
val onTap: UIAction
) : UIComponent()
// Register in ComponentRegistry
register(UIComponent.ProductCard::class) { component, handler ->
ProductCardComposable(
component as UIComponent.ProductCard,
onClick = { handler.handle(component.onTap) }
)
}
Best Practice: Keep your component set small and intentional. Too many components = complexity. Start with 10-15 core components covering layout, content, and interaction. Expand only when needed.
Wrapping Up
You now have the foundation for a server-driven UI system in Jetpack Compose:
- ✅ Type-safe component schema with sealed classes
- ✅ Flexible registry connecting types to Composables
- ✅ Recursive renderer handling nested structures
- ✅ Action system for handling user interactions
- ✅ State management for forms and dynamic content
Next steps: Add more components to your registry, set up caching for reliability, and integrate with your feature flag system for A/B testing.
Related Articles
- Server-Driven UI Tutorial: Building Dynamic Screens with SwiftUI →
- Server-Driven UI for Android: A Complete Implementation Guide →
- Getting Started with Pyramid: Add Server-Driven UI to Your Android App in 30 Minutes →
- SDUI vs Cross-Platform: Which Solves Your Mobile Release Problem? →
- SDUI Architecture Patterns: Building Scalable Server-Driven UI Systems →
- Getting Started with RemoteCompose: A Practical Guide (And When You'll Outgrow It) →
Skip the Build Phase
Pyramid provides a production-ready SDUI SDK with 50+ components, visual editor, and built-in A/B testing. Ship your first server-driven screen in days, not months.
Join the Waitlist