Server-Driven UI in Jetpack Compose: Play Store Policy Compliance Guide
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

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.
Quick Compliance Reference: Declarative Data vs Code Execution
| Dimension | Compliant SDUI Architecture | Prohibited Dynamic Code Loading |
|---|---|---|
| Payload Type | JSON, Protobuf, or GraphQL schema | Raw bytecode (.dex), dynamic .jar, or eval scripts |
| Rendering Engine | Pre-compiled native Compose components | Dynamic class loading via DexClassLoader |
| Logic Execution | Parameterized client actions (OPEN_URL, NAVIGATE) |
Arbitrary executable logic injected from server |
| Play Policy Rule | Complies with Device and Network Abuse policy | Violation: Alters binary executable behavior outside Play Store |
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:
- Never download compiled DEX, class files, or native libraries to evaluate on the device.
- Never send executable JavaScript or raw scripting strings in the payload to evaluate via an un-sandboxed engine.
- 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:
- 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.
- 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.
- Stable Identifiers: Ensure every component payload includes a stable, unique
id. In Jetpack Compose, pass this identifier tokey()orLazyColumnitems:
@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:
- Accessibility: Native screen readers (TalkBack) and focus order require extra client annotations that generic JSON payloads often miss.
- Testing complexity: Automated UI tests must account for dynamic component ordering and missing sections.
- Engineering synchronization: Changing a component contract requires coordinating both backend schema validators and client renderers.
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

Fix Android WebView onShowFileChooser Overlapping Callback
Learn how to correctly manage Android WebView onShowFileChooser callbacks, handle overlapping chooser requests, complete cancellations, and process URI results safely.

WorkManager Unique Periodic Work
Preventing duplicate work and race conditions with unique periodic work in Android WorkManager.

PagingSource Random API Duplicate Empty Batch Refresh
An exploration of why random data endpoints break standard pagination contracts, how PagingSource handles empty batches, and what trade-offs exist when pagination keys lack stable ordering.