Server-Driven UI Tutorial: Building Dynamic Screens with Jetpack Compose

Published: March 3, 2026 15 min read Intermediate

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

  1. Understanding the Architecture
  2. Defining Your Component Schema
  3. Building the Component Registry
  4. Creating the Rendering Engine
  5. Setting Up Networking
  6. Handling Actions and Events
  7. Managing State
  8. Advanced Patterns

Understanding the Architecture

A server-driven UI system has four core pieces:

  1. Schema: A contract defining what components exist and their properties
  2. Server: Sends JSON describing which components to render
  3. Registry: Maps component types to Composable implementations
  4. 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.

UIComponent.kt
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:

UIAction.kt
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:

ComponentRegistry.kt
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

Components.kt
@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:

SDUIRenderer.kt
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:

Example JSON Response
{
  "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:

Serialization.kt
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:

ActionProcessor.kt
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:

StateManager.kt
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:

Conditional Rendering (A/B Tests)

Add a Conditional component that renders different children based on feature flags:

Conditional.kt
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:

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

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

Related Articles