Topics
Recent articles

Android & Mobile

Server-Driven UI in Jetpack Compose: Dynamic Layouts Without Policy Violations

Build dynamic Android layouts in Jetpack Compose using server-driven UI schemas, unknown component fallbacks, and typed actions while respecting Google Play code execution policies.

Table of Contents7 sections
Visual illustration for Server-Driven UI in Jetpack Compose: Dynamic Layouts Without Policy Violations
Technical workflow diagram from RayLabs.

When native Android developers realize that true binary over-the-air code pushing is unavailable or risky on Google Play, the discussion usually pivots to Server-Driven UI (SDUI). The premise is appealing: instead of hardcoding every screen in Kotlin, the backend returns a declarative component tree describing what to display and how to order it. The Android app parses the payload and renders matching native Jetpack Compose components.

Done well, SDUI lets product teams reorder dashboard cards, launch holiday promotions, test banners, and adjust layout hierarchies in minutes without waiting for a store review cycle.

Done poorly, SDUI turns the client into a fragile, bug-prone web browser reimplementation that crashes on version mismatches and risks Google Play policy scrutiny.

The architectural challenge is not drawing components from JSON. It is defining a rigid boundary between declarative presentation data and executable application logic.

The Google Play Policy Boundary: Data vs Executable Code

The first rule of native Android SDUI is understanding where Google Play draws the line.

Google Play’s Device and Network Abuse policy explicitly states that an app distributed via Google Play may not modify, replace, or update itself using any method other than Google Play’s update mechanism. It specifically bans downloading executable code (such as dex files, JAR files, or shared native libraries) from a third-party source.

Server-Driven UI remains fully compliant because it downloads data, not code:

COMPLIANT (Data):
Backend  --->  JSON/Protobuf Schema  --->  Compiled Composable Catalog
The client interprets structured attributes and renders pre-compiled native UI.

VIOLATION (Executable Code):
Backend  --->  Raw Bytecode / JS Bundle  --->  Dynamic DexClassLoader / Eval Engine
The client downloads executable instructions that alter runtime binary capabilities.

To maintain this compliance boundary:

  1. Never download compiled DEX, class files, or native libraries to evaluate on the device.
  2. Never send executable JavaScript or raw scripting strings in the payload to evaluate via an un-sandboxed engine.
  3. Keep all business-critical validation, cryptography, payment logic, and platform permission checks inside the compiled Kotlin binary.

The backend dictates what registered component to show and with what data. The client dictates how that component behaves and renders.

The Core Schema Contract

A resilient SDUI architecture starts with a closed polymorphic schema. In modern Kotlin, kotlinx.serialization handles this through sealed interfaces and class discriminators.

Consider a dynamic home feed layout:

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ScreenResponse(
    val screenId: String,
    val schemaVersion: Int,
    val sections: List<UiComponentPayload>
)

@Serializable
sealed interface UiComponentPayload {
    val id: String
}

@Serializable
@SerialName("banner")
data class BannerComponent(
    override val id: String,
    val title: String,
    val subtitle: String? = null,
    val imageUrl: String,
    val action: UiActionPayload? = null
) : UiComponentPayload

@Serializable
@SerialName("horizontal_carousel")
data class CarouselComponent(
    override val id: String,
    val items: List<CarouselItemPayload>
) : UiComponentPayload

@Serializable
@SerialName("info_card")
data class InfoCardComponent(
    override val id: String,
    val headline: String,
    val bodyText: String
) : UiComponentPayload

Notice that every component is an immutable data transfer object. There are no functions, no event scripts, and no raw style sheets embedded in the payload.

Handling the Unknown Component Trap

The single most common production failure in mobile SDUI occurs when the backend releases a new component type before every user has updated their app.

Suppose backend introduces an interactive_poll component in version 2.4. A user running version 2.1 receives a JSON payload containing "type": "interactive_poll". If the JSON parser encounters an unregistered subtype, default deserialization throws a SerializationException, causing the entire screen to fail and show a blank error state.

Your client deserializer must treat unknown components as non-fatal:

import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass

// Register a fallback component for unrecognised types
@Serializable
@SerialName("unknown")
data class UnknownComponent(
    override val id: String = "unknown"
) : UiComponentPayload

val sduiJson = Json {
    ignoreUnknownKeys = true
    isLenient = true
    classDiscriminator = "type"
    serializersModule = SerializersModule {
        polymorphic(UiComponentPayload::class) {
            subclass(BannerComponent::class)
            subclass(CarouselComponent::class)
            subclass(InfoCardComponent::class)
            defaultDeserializer { UnknownComponent.serializer() }
        }
    }
}

When rendering the Compose tree, the renderer simply ignores UnknownComponent or displays a graceful placeholder:

@Composable
fun RenderComponent(
    component: UiComponentPayload,
    onAction: (UiAction) -> Unit,
    modifier: Modifier = Modifier
) {
    when (component) {
        is BannerComponent -> BannerView(component, onAction, modifier)
        is CarouselComponent -> CarouselView(component, onAction, modifier)
        is InfoCardComponent -> InfoCardView(component, modifier)
        is UnknownComponent -> {
            // Log analytics for unsupported component to measure adoption,
            // but keep UI rendering intact without crashing.
        }
    }
}

This simple fallback rule decouples backend feature launches from client release schedules, allowing gradual server rollouts without breaking older installations.

Typed Actions: Eliminating Arbitrary Execution

Components often need to trigger interactions: opening a deep link, navigating to a profile, refreshing a feed, or initiating checkout.

Never allow the server to transmit arbitrary intent URIs or raw scripts. Instead, model interactions as a sealed hierarchy of typed actions:

@Serializable
sealed interface UiActionPayload

@Serializable
@SerialName("navigate")
data class NavigateAction(
    val route: String,
    val arguments: Map<String, String> = emptyMap()
) : UiActionPayload

@Serializable
@SerialName("open_url")
data class OpenUrlAction(
    val url: String
) : UiActionPayload

@Serializable
@SerialName("refresh_feed")
data class RefreshFeedAction(
    val feedId: String
) : UiActionPayload

On the Android client, an application action dispatcher validates the action before execution:

class SduiActionHandler(
    private val navController: NavController,
    private val customTabsLauncher: (String) -> Unit
) {
    fun handleAction(action: UiActionPayload) {
        when (action) {
            is NavigateAction -> {
                val safeRoute = sanitizeRoute(action.route)
                navController.navigate(safeRoute)
            }
            is OpenUrlAction -> {
                if (action.url.startsWith("https://")) {
                    customTabsLauncher(action.url)
                }
            }
            is RefreshFeedAction -> {
                // Trigger viewmodel state reload
            }
        }
    }

    private fun sanitizeRoute(route: String): String {
        // Enforce allowed destination whitelist
        return route.takeIf { it in ALLOWED_ROUTES } ?: "home"
    }
}

By constraining action routing through an explicit client-side whitelist, malicious or malformed backend responses cannot navigate users to unauthorized flows, trigger unintended permissions, or execute unintended side effects.

State Management and Flicker Prevention

Rendering screens from remote JSON introduces latency. If the app displays a full-screen loading spinner every time the user opens the home tab, the user experience deteriorates quickly compared to a statically compiled native screen.

To maintain 60fps performance and instant display:

  1. Local Snapshot Caching: Store the latest successfully parsed payload in Room or DataStore. On app launch, immediately render the cached layout while triggering a background refresh.
  2. Stale-While-Revalidate: Update the UI only when the backend payload SHA has changed. If the new layout matches the current layout, skip re-composition.
  3. Stable Identifiers: Ensure every component payload includes a stable, unique id. In Jetpack Compose, pass this identifier to key() or LazyColumn items:
@Composable
fun SduiScreen(
    sections: List<UiComponentPayload>,
    onAction: (UiActionPayload) -> Unit
) {
    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(
            items = sections,
            key = { component -> component.id }
        ) { component ->
            RenderComponent(
                component = component,
                onAction = onAction
            )
        }
    }
}

Using stable item keys prevents Compose from destroying and recreating stateful components (such as scroll positions in carousels) when other sections of the screen update.

When SDUI Is the Wrong Tool

Server-Driven UI is powerful for promotional spaces, modular dashboards, and dynamic content feeds. However, it introduces significant architectural overhead:

For binary updates, security patches, and heavy refactors, standard native Android over-the-air updates via Google Play In-App Updates remain the correct platform solution. Similarly, for managing mandatory migrations across older versions, pairing a simple forced update policy with staged rollouts provides a much cleaner boundary than trying to make every native screen server-driven.

Use SDUI where editorial or merchandising velocity justifies the schema investment. Keep core workflows, navigation shells, and complex device interactions in compiled Kotlin.

Continue Exploring

You Might Also Like

View all articles
Why Android Wireless Debugging Keeps Turning Off
4 min read

Why Android Wireless Debugging Keeps Turning Off

An analysis of why Android wireless debugging toggles reset during sleep, examining how to fix the issue with hub-and-spoke content strategies rather than risky title rewrites.