How Mari san moved from quick fixes to a calm native first approach with Server Driven UI. 

TL;DR

  • WebViews feel fast to ship but they bring performance drag, security risk and a rough developer life.
  • Server Driven UI sends a clear schema from the server and renders native components on the client so you get dynamic content with native speed.
  • Kotlin Multiplatform plus Compose gives one shared engine with native apps on each platform so teams ship faster with fewer surprises in production.

Mari san leads a small mobile team at a growing fintech startup. The product is moving fast, the compliance bar is high, and the app has a few places that change every week. Banners, onboarding, account verification, a promo on the home screen. To move quickly, the team leaned on WebViews. It worked, until the cracks became hard to ignore.

At first, everyone celebrated the speed. A small server change could update the screen the same day. No more delays due to Apps store reviews, no more waiting on the client release. Then the rough edges appeared. Some screens felt heavy, things were hard to get right, and debugging took longer than building the feature. Security reviews grew tense because the team had to reason about page behavior they did not fully control.

This is the story of how Mari san and her team kept the agility they loved while giving users a smoother, safer experience. They did it with a server driven approach that renders native components on the device, and they shared logic across platforms with Kotlin Multiplatform and Compose.

What made WebViews attractive, and why that changed

WebViews offer quick updates and design reuse, useful for rapid policy changes in fintech. However, product reliance on them causes uneven performance, increased memory use, and complex native feature integration. WebViews are better suited for light document layers or low-risk pages, not as core mobile banking features.

SDUI — The alternative that kept the team moving

Instead of shipping a full HTML page, the backend sends a compact description of the screen, often as JSON. The app parses that description and renders real native components. Update the description on the server and the app updates instantly, while staying smooth and reliable because the view is rendered with native widgets.

This pattern is usually called server driven UI. The key idea is simple:

  • Server decides what to show
  •  client renders as per the contract

A simple mental model

  • The server returns a screen description that includes layout, components, styles, and actions.
  • The app maps each described component to a native view
  • Events from the view either run locally or get sent back to the server
  • The team can version the schema so older clients remain safe

Users feel a native app. The product still moves quickly.

A quick look at the building blocks

Below are tiny examples that capture the shape of such a system. They are small on purpose so you can see the idea clearly.

A tiny JSON description

{
  "type": "screen",
  "id": "home",
  "children": [
    {
      "type": "text",
      "text": "Welcome back, Mari"
    },
    {
      "type": "button",
      "text": "See account",
      "action": {
        "type": "nav",
        "route": "/account"
      }
    },
    {
      "type": "banner",
      "title": "Earn rewards",
      "body": "Activate your card to start earning",
      "action": {
        "type": "deeplink",
        "url": "paypay://rewards"
      }
    }
  ],
  "style": {
    "padding": 16
  }
}

Data classes for parsing

@Serializable
sealed class Node {
    abstract val id: String?
}

@Serializable
data class Screen(
    override val id: String? = null,
    val children: List<Node>,
    val style: Style? = null
) : Node()

@Serializable
data class Text(
    override val id: String? = null,
    val text: String,
    val style: TextStyle? = null
) : Node()

@Serializable
data class Button(
    override val id: String? = null,
    val text: String,
    val action: Action? = null
) : Node()

@Serializable
data class Banner(
    override val id: String? = null,
    val title: String,
    val body: String,
    val action: Action? = null
) : Node()

A tiny action model

@Serializable
sealed class Action {
  @Serializable
  data class Nav(val route: String) : Action()

  @Serializable
  data class Deeplink(val url: String) : Action()

  @Serializable
  data class Track(
    val name: String, 
    val props: Map<String, String>
  ) : Action()
}

Mapping nodes to Compose

@Composable
fun Render(node: Node, onAction: (Action) -> Unit) {
  when (node) {
    is Screen -> Column(modifier = Modifier.padding((node.style?.padding ?: 0).dp)) {
      node.children.forEach { child -> Render(child, onAction) }
    }
    is Text -> Text(text = node.text, style = node.style?.toTextStyle() ?: LocalTextStyle.current)
    is Button -> androidx.compose.material3.Button(onClick = { node.action?.let(onAction) }) {
      Text(node.text)
    }
    is Banner -> Card {
      Column(Modifier.padding(16.dp)) {
        Text(node.title)
        Text(node.body)
        Spacer(Modifier.height(8.dp))
        androidx.compose.material3.Button(onClick = { node.action?.let(onAction) }) {
          Text("Do it")
        }
      }
    }
  }
}

Handling actions

fun handleAction(
  action: Action, 
  nav: Navigator, 
  deep: DeepLinker, 
  analytics: Analytics
) {
  when (action) {
    is Action.Nav -> nav.navigate(action.route)
    is Action.Deeplink -> deep.open(action.url)
    is Action.Track -> analytics.track(action.name, action.props)
  }
}

These pieces are enough to render the example screen with native views, respond to taps, and track events, all without shipping a WebView.

Why this worked well for a fintech team

Fintech apps live under a careful mix of security, performance, and compliance. Mari san needed a way to change copy, layouts, and small flows without forcing a client release, and she needed predictable performance and reliability.

A server driven approach checked these boxes:

  • Smooth feel because the UI is rendered with native components
  • Fast iteration because the server controls what appears
  • Safer updates because the client only executes declared actions you allow
  • Clear analytics because events are defined and handled in one place
  • Cross platform consistency because the same description can drive Android and iOS

Common alternatives, and when to use them

  • More WebViews. Good for lightweight documents, help pages, and legal content. Not ideal for core money flows or places where animation, input, and offline behavior matter.
  • Fully native everywhere. Best performance and full device access. Slower for rapid iteration across platforms unless you invest in tooling.
  • A single cross platform UI toolkit. Solid option when you want one codebase to draw the entire app. Still consider how you will do server controlled changes and gradual rollout.

The server driven approach is not a replacement for all of the above. It is a complement. Use it where you want native feel and rapid updates without a client release.

Design choices that kept the team calm

One clear schema

Keep the format minimal and explicit. Put names on everything you plan to track. Prefer optional fields with sensible defaults. Add a version field so the server can send the right shape to each app version.

{
  "schemaVersion": 3,
  "type": "screen",
  "id": "home",
  "children": [
    /* ... */
  ]
}

Strict validation before render

Validate unknown types, missing fields, and unsafe actions before you try to draw anything. If validation fails, show a safe fallback view and log the error with full context.

fun validate(node: Node): List<String> {
    val errors = mutableListOf<String>()
    when (node) {
        is Screen -> if (node.children.isEmpty()) errors += "Screen needs children"
        is Button -> if (node.text.isBlank()) errors += "Button text required"
        // add more checks as needed
    }
    return errors
}

A small design system

Give the renderer a library of reusable building blocks. Card, banner, hero, list row, form field. Designers get predictable results, engineers get predictable performance.

Feature flags and gradual rollout

Serve the schema behind a flag and allow a small percentage first. Roll forward when metrics look healthy.

Clear analytics and traceability

Attach stable ids to nodes and send them with events. That way your dashboards tell you exactly which layout and copy drove a change in conversion.

Kotlin Multiplatform and Compose in practice

Kotlin Multiplatform lets the team share the parser, the validator, the action model, and most of the renderer. Android used Compose directly. iOS used a lightweight host that bridged to native views or to Compose where that made sense. The pitch is not perfect code reuse. The pitch is sharing the boring and careful parts that should be consistent across platforms.

Benefits the team felt within the first release cycle:

  • One place to evolve the schema and validation
  • Fewer inconsistencies between platforms
  • A shared test suite for the server to screen path
  • Faster onboarding for new engineers

Where to start in a real app

Pick one slice that changes often and is safe to iterate. Good candidates in fintech include onboarding, KYC document capture wrappers around the native capture view, the home banner area, and post purchase or post transfer feedback.

A simple plan that worked for Mari san:

  1. Build the minimal renderer and a handful of components
  2. Add strict validation with a safe fallback
  3. Ship one small screen behind a flag
  4. Wire analytics and a feedback channel from support to the team
  5. Expand to one or two more components only after the first slice is stable

Trade-offs to consider

  • More moving parts on the server. You will need versioning, preview tools, and change review. The payoff is faster iteration without a client release.
  • Learning curve for the team. Designers, product, and QA will learn to think in components and schema. A simple preview app helps a lot.
  • Guardrails are not optional. Validation, action allow lists, and monitoring are part of the system, not add ons.
  • Offline support. The server may fail to respond, the framework needs to take care of fallback when it happens. 

A short decision guide

  • Choose WebViews for content that is truly document like or very low risk
  • Choose fully native screens for flows that must squeeze every millisecond or use deep device features
  • Choose server driven UI when you want native feel and quick changes to layout and copy without a client release

Closing

Mari san and her team did not chase novelty, instead looked for calm. Server driven UI gave them the freedom to adapt daily while keeping the experience fast and trustworthy. Kotlin Multiplatform and Compose helped them share the careful plumbing across platforms so they could focus on the parts of a fintech app that earn trust every day.

If you are in a similar spot, try one small screen. Measure how it feels. Measure the time it saves. Then take the next step.

Discover more from Product Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading