Server-driven UI SDK on Android: how RevenueCat enables remote paywalls without app updates

Learning server-driven UI by exploring RevenueCat's Android SDK.

Jaewoong Eum
Published

Typically, mobile applications are responsible for rendering UI layouts based on domain data received from the backend, meaning that the layout structure is largely determined on the client side.

However, there’s an alternative approach called server-driven UI, where the backend sends not only the domain data but also layout information to the mobile client. The client then renders the UI based on the backend-provided structure.

This allows teams to update UI layouts without going through the traditional release cycle—modifying the layout in code, publishing to Google Play, waiting for review, and requiring users to update the app.

In this article, we’ll explore the concept of server-driven UI, with a focus on how RevenueCat’s Paywall Editor enables you to build and remotely update paywalls—without needing app updates—using RevenueCat’s Android SDK.

RevenueCat’s Paywall Editor

RevenueCat’s Paywall Editor provides a Figma-like interface that lets you design and customize paywall screens directly from the web dashboard—no mobile development expertise required.

Designers or product managers can easily update paywall screens themselves, and once changes are published, they’re instantly reflected on users’ devices without needing an app update.

This is one of the key advantages of using the RevenueCat SDK for building paywalls: your team can focus more on A/B testing, experimenting with different offerings and UI layouts, and iterating on messaging strategies to drive subscriptions—all without the lengthy process of app updates, review cycles, and waiting for users to install the latest version.

We’ve recently launched Paywalls v2 out of beta, now stable and ready for production. It enables you to build fully customizable, fully native paywalls remotely using the Paywall Editor. For more details, check out: Announcing RevenueCat Paywalls v2 GA – Fully native, free, high-performance paywalls.

Approaches to server-driven UI

So what’s the core mechanism behind server-driven UI (SDUI), and how does RevenueCat’s Paywall Editor make it possible? As mentioned earlier, SDUI shifts the responsibility from the client to the server by having the API return not just raw domain data, but also layout information. This allows the UI to be rendered dynamically on the client side, minimizing the need for platform-specific code as illustrated below.

As illustrated above, the backend takes full responsibility for composing the payload, which includes both domain data and UI layout definitions. This makes the client side more passive, simply receiving the response and rendering the UI as instructed. As a result, mobile developers can focus on building modular, reusable UI components rather than managing complex layout logic or data binding themselves.

However, building a full SDUI system from scratch is no small task. It requires the backend to define the complete layout structure, often involving tight coordination between mobile and backend teams. You’ll also need to manage protocol definitions, schema design, UI versioning, and ensure backward compatibility through rigid contracts.

RevenueCat eliminates this overhead entirely. With its Paywall Editor, you can easily design, publish, and update paywall screens without writing custom SDUI infrastructure or releasing new app builds.

Key advantages of the server-driven UI

So, what makes a server-driven UI such a game changer? Here are a few key advantages you can take full advantage of:

Accelerated feature testing: Quickly iterate and deploy new paywall layouts without requiring app updates, enabling a faster feedback loop and more agile experimentation.

True native performance: Enjoy the flexibility of server-driven UI while maintaining the smooth, high-performance rendering of fully native components.

Less overhead for developers: Product managers and designers can own the layout and paywall structure, freeing mobile developers to focus on building reusable components that respond to backend-driven configurations.

Consistent experience across platforms and versions: With a well-defined component system and stable core specs, users get a consistent UI and behavior, even across different app versions and different platforms across Android, and iOS.

So once you’ve built the screen using RevenueCat’s APIs, product managers, designers, or other non-technical team members can easily update the paywall remotely—no coding required. This gives your team greater flexibility to run A/B tests and experiments, helping you continuously optimize for higher revenue.

Demystifying the SDK internals

Now, let’s take a look at how the magic happens on the SDK side. The Paywalls Components documentation provides a full list of UI elements you can dynamically build using the Paywall Editor, much like working in Figma. It supports components like Text, Image, Icon, Stack, Purchase Button, Carousel, Social Proof, and Feature List, all of which can be customized effortlessly by adjusting their properties directly in the editor.

Essentially, the RevenueCat backend packages all component data and layout structure into a JSON payload, allowing for flexible and dynamic rendering on the client side. On Android, this JSON is deserialized using the PaywallComponentSerializer, which converts the server-provided data into usable objects for native rendering, as shown in the code below.

1@Serializable(with = PaywallComponentSerializer::class)
2sealed interface PaywallComponent
3
4@InternalRevenueCatAPI
5internal class PaywallComponentSerializer : KSerializer<PaywallComponent> {
6
7    override fun deserialize(decoder: Decoder): PaywallComponent {
8        val jsonDecoder = decoder as? JsonDecoder
9            ?: throw SerializationException("Can only deserialize PaywallComponent from JSON, got: ${decoder::class}")
10        val json = jsonDecoder.decodeJsonElement().jsonObject
11        return when (val type = json["type"]?.jsonPrimitive?.content) {
12            "button" -> jsonDecoder.json.decodeFromString<ButtonComponent>(json.toString())
13            "image" -> jsonDecoder.json.decodeFromString<ImageComponent>(json.toString())
14            "package" -> jsonDecoder.json.decodeFromString<PackageComponent>(json.toString())
15            "purchase_button" -> jsonDecoder.json.decodeFromString<PurchaseButtonComponent>(json.toString())
16            "stack" -> jsonDecoder.json.decodeFromString<StackComponent>(json.toString())
17            "sticky_footer" -> jsonDecoder.json.decodeFromString<StickyFooterComponent>(json.toString())
18            "text" -> jsonDecoder.json.decodeFromString<TextComponent>(json.toString())
19            "icon" -> jsonDecoder.json.decodeFromString<IconComponent>(json.toString())
20            "timeline" -> jsonDecoder.json.decodeFromString<TimelineComponent>(json.toString())
21            "carousel" -> jsonDecoder.json.decodeFromString<CarouselComponent>(json.toString())
22            "tab_control_button" -> jsonDecoder.json.decodeFromString<TabControlButtonComponent>(json.toString())
23            "tab_control_toggle" -> jsonDecoder.json.decodeFromString<TabControlToggleComponent>(json.toString())
24            "tab_control" -> jsonDecoder.json.decodeFromString<TabControlComponent>(json.toString())
25            "tabs" -> jsonDecoder.json.decodeFromString<TabsComponent>(json.toString())
26            else -> json["fallback"]
27                ?.let { it as? JsonObject }
28                ?.toString()
29                ?.let { jsonDecoder.json.decodeFromString<PaywallComponent>(it) }
30                ?: throw SerializationException("No fallback provided for unknown type: $type")
31        }
32    }
33    
34    ..
35}

As shown in the code above, PaywallComponentSerializer parses the JSON component data into a normalized PaywallComponent sealed interface. Each specific component—like ButtonComponent, ImageComponent, and TextComponent—implements this interface and is consumed on the UI side for rendering.

If we take a closer look at TextComponent, the class holds all the detailed properties needed to style and render a text element. It’s then transformed into a TextComponentStyle, which focuses purely on the visual styling required to build the component. 

During this transformation, additional processing occurs, such as string localization, applying custom fonts, setting colors and background, handling padding and margins, and adjusting visibility, among other refinements.

1@Serializable
2@SerialName("text")
3class TextComponent constructor(
4    @get:JvmSynthetic
5    @SerialName("text_lid")
6    val text: LocalizationKey,
7    @get:JvmSynthetic
8    val color: ColorScheme,
9    @get:JvmSynthetic
10    val visible: Boolean? = null,
11    @get:JvmSynthetic
12    @SerialName("background_color")
13    val backgroundColor: ColorScheme? = null,
14    @get:JvmSynthetic
15    @SerialName("font_name")
16    val fontName: FontAlias? = null,
17    .. // properties for describing a Text component
18  )
19
20private fun StyleFactoryScope.createTextComponentStyle(
21    component: TextComponent,
22): Result<TextComponentStyle, NonEmptyList<PaywallValidationError>> = zipOrAccumulate(
23    // Get our texts from the localization dictionary.
24    first = localizations.stringForAllLocales(component.text),
25    ..
26  ) { texts, presentedOverrides, color, backgroundColor, fontSpec ->
27    val weight = component.fontWeight.toFontWeight()
28    TextComponentStyle(
29        texts = texts,
30        color = color,
31        fontSize = component.fontSize,
32        fontWeight = weight,
33        fontSpec = fontSpec,
34        textAlign = component.horizontalAlignment.toTextAlign(),
35        horizontalAlignment = component.horizontalAlignment.toAlignment(),
36        backgroundColor = backgroundColor,
37        ..
38   )
39}

The TextComponentStyle is then used to apply the customizations defined by the user in RevenueCat’s Paywall Editor to a Jetpack Compose UI element called TextComponentView. This bridges the styling configuration from the backend to the actual visual rendering on the client.

1@Composable
2internal fun TextComponentView(
3    style: TextComponentStyle,
4    state: PaywallState.Loaded.Components,
5    modifier: Modifier = Modifier,
6) {
7    // Get a TextComponentState that calculates the overridden properties we should use.
8    val textState = rememberUpdatedTextComponentState(
9        style = style,
10        paywallState = state,
11    )
12
13    // Process any variables in the text.
14    val text = rememberProcessedText(
15        state = state,
16        textState = textState,
17    )
18
19    val colorStyle = textState.color.forCurrentTheme
20    val backgroundColorStyle = textState.backgroundColor?.forCurrentTheme
21
22    // Get the text color if it's solid.
23    val color = when (colorStyle) {
24        is ColorStyle.Solid -> colorStyle.color
25        is ColorStyle.Gradient -> Color.Unspecified
26    }
27    // Create a TextStyle with gradient if necessary.
28    // Remove the line height, as that's not configurable anyway, so we should let Text decide the line height.
29    val textStyle = when (colorStyle) {
30        is ColorStyle.Solid -> LocalTextStyle.current.copy(
31            lineHeight = TextUnit.Unspecified,
32        )
33        is ColorStyle.Gradient -> LocalTextStyle.current.copy(
34            lineHeight = TextUnit.Unspecified,
35            brush = colorStyle.brush,
36        )
37    }
38
39    if (textState.visible) {
40        Markdown(
41            text = text,
42            modifier = modifier
43                .size(textState.size, horizontalAlignment = textState.horizontalAlignment)
44                .padding(textState.margin)
45                .applyIfNotNull(backgroundColorStyle) { background(it) }
46                .padding(textState.padding),
47            color = color,
48            fontSize = textState.fontSize.sp,
49            fontWeight = textState.fontWeight,
50            fontFamily = textState.fontFamily,
51            horizontalAlignment = textState.horizontalAlignment,
52            textAlign = textState.textAlign,
53            style = textStyle,
54        )
55    }
56}

Eventually, all component types, such as TextComponentView, StackComponentView, CarouselComponentView, and others are consumed and rendered by a central ComponentView. This view determines which specific component to display based on the provided style instance, ensuring each element is rendered appropriately according to its configuration.

1@Composable
2internal fun ComponentView(
3    style: ComponentStyle,
4    state: PaywallState.Loaded.Components,
5    onClick: suspend (PaywallAction) -> Unit,
6    modifier: Modifier = Modifier,
7) = when (style) {
8    is StackComponentStyle -> StackComponentView(style = style,state = state,clickHandler = onClick,modifier = modifier)
9    is TextComponentStyle -> TextComponentView(style = style,state = state,modifier = modifier)
10    is ImageComponentStyle -> ImageComponentView(style = style, state = state, modifier = modifier)
11    is ButtonComponentStyle -> ButtonComponentView(style = style, state = state, onClick = onClick, modifier = modifier)
12    is StickyFooterComponentStyle -> StickyFooterComponentView(
13        style = style,
14        state = state,
15        clickHandler = onClick,
16        modifier = modifier,
17    )
18    is PackageComponentStyle -> PackageComponentView(
19        style = style,
20        state = state,
21        clickHandler = onClick,
22        modifier = modifier,
23    )
24    ..
25)

Now, everything becomes much simpler. To build an entire screen or section using fully customized components and layouts from the remote editor, you can simply place the ComponentView at the appropriate point in your UI. Internally, the Paywall composable that renders the full paywall screen can be as straightforward as the example below:

1@Composable
2internal fun LoadedPaywallComponents(
3    state: PaywallState.Loaded.Components,
4    clickHandler: suspend (PaywallAction) -> Unit,
5    modifier: Modifier = Modifier,
6) {
7    val configuration = LocalConfiguration.current
8    state.update(localeList = configuration.locales)
9
10    val style = state.stack
11    val footerComponentStyle = state.stickyFooter
12    val background = rememberBackgroundStyle(state.background)
13
14    Column(modifier = modifier.background(background)) {
15        ComponentView(
16            style = style,
17            state = state,
18            onClick = clickHandler,
19            modifier = Modifier
20                .fillMaxWidth()
21                .weight(1f)
22                .verticalScroll(rememberScrollState()),
23        )
24        footerComponentStyle?.let {
25            ComponentView(
26                style = it,
27                state = state,
28                onClick = clickHandler,
29                modifier = Modifier
30                    .fillMaxWidth(),
31            )
32        }
33    }
34}

At the highest level, the paywall API is as simple as the example below, offering a clean and minimal integration point:

1/**
2 * Composable offering a full screen Paywall UI configured from the RevenueCat dashboard.
3 * @param options The options to configure the [Paywall] if needed.
4 */
5@Composable
6fun Paywall(options: PaywallOptions) {
7    InternalPaywall(options) // wrapper of the `LoadedPaywallComponents`
8}

Conclusion

In this article, you explored the core concepts of server-driven UI, how RevenueCat’s paywall system leverages this approach, and how the Android SDK dynamically renders components received from the RevenueCat backend. 

Server-driven UI offers clear advantages: it eliminates the need for rebuilds and store resubmissions, shortens feedback loops, and enables rapid experimentation with new offerings to optimize revenue. Building such a system from scratch is resource-intensive, but with RevenueCat, all the heavy lifting is handled for you, allowing your team to focus on what matters most: growing your business.

As always, happy coding!

Jaewoong

You might also like

Share this post

Want to see how RevenueCat can help?

RevenueCat enables us to have one single source of truth for subscriptions and revenue data.

Olivier Lemarié, PhotoroomOlivier Lemarié, Photoroom
Read Case Study