← Back to Blog

How to Migrate to Server-Driven UI: A Step-by-Step Guide for Mobile Teams

You don't have to rewrite your app to adopt SDUI. The best migrations are incremental — starting with config flags and progressing to full server control. This guide walks you through every level, with code examples, timelines, and lessons from teams who've done it.

1. Why Migrate to Server-Driven UI?

Before we get into the how, let's be honest about the why. Server-driven UI migration is a significant investment. You need a clear-eyed understanding of the problem you're solving.

The Release Bottleneck

Mobile teams face a fundamental constraint that web teams don't: every UI change requires an app store release. That means:

Your web team changes a button label in 5 minutes. Your mobile team needs a sprint to do the same thing safely.

The Business Case

This isn't just a developer experience problem. Slow releases have measurable business impact:

💡 Key insight
SDUI migration isn't about replacing your entire app. It's about identifying the screens that change most and making them server-driven. Most teams find that 20% of screens account for 80% of release pressure.

When NOT to Migrate

To be fair, SDUI migration isn't the right move for every team:

If none of those apply and you're feeling the release bottleneck, keep reading.

2. Prerequisites Assessment

Before writing any migration code, do an honest assessment. Teams that skip this phase end up building the wrong thing.

Team Readiness Checklist

Architecture Audit

Evaluate your current codebase with these questions:

  1. How are screens structured? Are they monolithic activities/view controllers, or do they compose smaller reusable components? Component-based architectures (Compose, SwiftUI) are easier to migrate.
  2. Where does data flow? Is there a clear separation between data fetching and UI rendering? If your API responses already describe "what to show" (not just raw data), you're closer than you think.
  3. Which screens change most? Audit your git history. The screens with the most commits in the last 6 months are your migration candidates.
  4. What's your offline story? If your app needs to work without network, plan your caching strategy before starting.
🎯 Pick your pilot screen
Choose one screen for your first SDUI migration. Ideal candidates: home/feed screens, onboarding flows, settings pages, or promotional surfaces. Avoid: login screens, payment flows, or anything with complex platform-specific behavior.

3. The 5 Migration Levels

SDUI migration isn't binary — it's a spectrum. We break it into 5 maturity levels, each delivering incremental value. You don't have to reach Level 4 to benefit. Most teams get massive ROI at Level 2.

Level Name What Changes Who Benefits
0 Config Flags Feature toggles, simple values Engineering
1 Dynamic Content Text, images, ordering Product + Marketing
2 Dynamic Layouts Component composition Product + Design
3 Full SDUI Entire screens from server Entire org
4 Zero-Release Visual editor, A/B testing, no code Business + Growth

Let's walk through each level with concrete code examples.

4. Level 0: Config Flags

Level 0 Remote Configuration

⏱ Timeline: 1-2 weeks 👥 Team: 1 mobile engineer ⚡ Impact: Low-medium

Control feature visibility and simple values from your server. This is the gentlest on-ramp — you're probably already doing some version of this with Firebase remote config or LaunchDarkly.

At Level 0, your layouts remain hardcoded. The server only controls which features are visible and simple configuration values like strings and colors. The key distinction from a feature flag service: you own the contract and can evolve it toward SDUI.

Kotlin / Jetpack Compose

// Level 0: Server-controlled configuration
@Serializable
data class ScreenConfig(
    val showPromoBanner: Boolean = false,
    val promoBannerText: String = "",
    val ctaButtonLabel: String = "Get Started",
    val maxItemsToShow: Int = 10
)

@Composable
fun HomeScreen(config: ScreenConfig) {
    Column {
        if (config.showPromoBanner) {
            PromoBanner(text = config.promoBannerText)
        }

        ProductList(maxItems = config.maxItemsToShow)

        Button(onClick = { /* navigate */ }) {
            Text(config.ctaButtonLabel)
        }
    }
}

Swift / SwiftUI

// Level 0: Server-controlled configuration
struct ScreenConfig: Codable {
    var showPromoBanner: Bool = false
    var promoBannerText: String = ""
    var ctaButtonLabel: String = "Get Started"
    var maxItemsToShow: Int = 10
}

struct HomeScreen: View {
    let config: ScreenConfig

    var body: some View {
        VStack {
            if config.showPromoBanner {
                PromoBanner(text: config.promoBannerText)
            }

            ProductList(maxItems: config.maxItemsToShow)

            Button(config.ctaButtonLabel) {
                // navigate
            }
        }
    }
}

What you gain: Toggle features without releases. Change copy. Run simple on/off experiments. It's limited, but it's a foundation.

Key architectural decision: Even at Level 0, define your config endpoint and response format intentionally. This will evolve into your SDUI schema. Don't just use Firebase Remote Config as a string-to-string dictionary — structure it.

5. Level 1: Dynamic Content

Level 1 Server-Driven Content

⏱ Timeline: 2-4 weeks 👥 Team: 1-2 engineers (mobile + backend) ⚡ Impact: Medium

The server controls what content appears, but layouts stay native. Think: section titles, image URLs, item ordering, and content blocks — not component structure.

At Level 1, you move from "the server tells us which features are on" to "the server tells us what content to show." Layouts are still hardcoded, but the server controls text, images, ordering, and which content blocks appear.

Kotlin / Jetpack Compose

// Level 1: Server controls content, client controls layout
@Serializable
data class HomeContent(
    val heroTitle: String,
    val heroImageUrl: String,
    val sections: List<ContentSection>
)

@Serializable
data class ContentSection(
    val id: String,
    val title: String,
    val type: String, // "product_grid", "banner", "text_block"
    val items: List<ContentItem> = emptyList(),
    val visible: Boolean = true
)

@Composable
fun HomeScreen(content: HomeContent) {
    LazyColumn {
        item {
            HeroSection(
                title = content.heroTitle,
                imageUrl = content.heroImageUrl
            )
        }
        
        items(content.sections.filter { it.visible }) { section ->
            when (section.type) {
                "product_grid" -> ProductGrid(section)
                "banner" -> PromoBanner(section)
                "text_block" -> TextBlock(section)
                else -> { /* skip unknown types */ }
            }
        }
    }
}

What you gain: Marketing can change homepage content without releases. You can reorder sections, swap hero images, and update copy server-side. This is where product and marketing teams start to feel the benefit.

The critical shift: Notice the when (section.type) pattern. This is the seed of a component registry pattern. At Level 1 it's a switch statement; at Level 3 it becomes a proper registry.

6. Level 2: Dynamic Layouts

Level 2 Server-Driven Layouts

⏱ Timeline: 4-8 weeks 👥 Team: 2-3 engineers ⚡ Impact: High

The server defines which components appear, in what order, and with what properties. You're now composing screens from a component vocabulary, not just filling in content slots.

Level 2 is where the real SDUI migration begins. Instead of the client deciding "hero goes here, then grid, then banner," the server sends a list of components and the client renders them in order. The server controls composition.

Kotlin / Jetpack Compose

// Level 2: Server defines component composition
@Serializable
data class UIComponent(
    val type: String,
    val props: Map<String, JsonElement> = emptyMap(),
    val children: List<UIComponent> = emptyList()
)

// Component registry — maps types to renderers
class ComponentRegistry {
    private val renderers = mutableMapOf<String, ComponentRenderer>()

    fun register(type: String, renderer: ComponentRenderer) {
        renderers[type] = renderer
    }

    fun resolve(type: String): ComponentRenderer? = renderers[type]
}

typealias ComponentRenderer = @Composable (
    props: Map<String, JsonElement>,
    children: List<UIComponent>
) -> Unit

// Register your existing design system components
val registry = ComponentRegistry().apply {
    register("HeroBanner") { props, _ ->
        HeroBanner(
            title = props["title"]?.asString() ?: "",
            imageUrl = props["imageUrl"]?.asString() ?: ""
        )
    }
    register("ProductCarousel") { props, _ ->
        ProductCarousel(
            categoryId = props["categoryId"]?.asString() ?: "",
            title = props["title"]?.asString() ?: ""
        )
    }
    register("InfoCard") { props, _ ->
        InfoCard(
            text = props["text"]?.asString() ?: "",
            style = props["style"]?.asString() ?: "default"
        )
    }
}

// Render any screen from server response
@Composable
fun DynamicScreen(components: List<UIComponent>) {
    LazyColumn {
        items(components) { component ->
            val renderer = registry.resolve(component.type)
            if (renderer != null) {
                renderer(component.props, component.children)
            }
            // Unknown types are silently skipped — graceful degradation
        }
    }
}

Swift / SwiftUI

// Level 2: Server defines component composition
struct UIComponent: Codable, Identifiable {
    let id: String
    let type: String
    let props: [String: AnyCodable]
    let children: [UIComponent]
}

struct DynamicScreen: View {
    let components: [UIComponent]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(components) { component in
                    resolveComponent(component)
                }
            }
        }
    }

    @ViewBuilder
    func resolveComponent(_ component: UIComponent) -> some View {
        switch component.type {
        case "HeroBanner":
            HeroBanner(
                title: component.props["title"]?.stringValue ?? "",
                imageURL: component.props["imageUrl"]?.stringValue ?? ""
            )
        case "ProductCarousel":
            ProductCarousel(
                categoryId: component.props["categoryId"]?.stringValue ?? "",
                title: component.props["title"]?.stringValue ?? ""
            )
        default:
            EmptyView() // graceful fallback
        }
    }
}

What you gain: The server can now rearrange your home screen, add new sections, remove them, personalize per user — all without a release. This is where most teams say "oh, this is what the fuss is about."

🏆 Level 2 is the sweet spot
For most teams, Level 2 delivers 80% of the value with 30% of the complexity of full SDUI. You're reusing your existing components, just letting the server decide how to compose them. If you stop here, you're still in a great place.

7. Level 3: Full SDUI

Level 3 Full Server-Driven UI

⏱ Timeline: 2-4 months 👥 Team: 3-5 engineers ⚡ Impact: Very high

Complete screens are defined on the server — layout trees, nested components, actions, state management. The client is a generic rendering engine.

Level 3 is where you build a true SDUI system. The server sends a complete component tree — not just a flat list, but nested components with layout containers, styling properties, and action handlers. The client becomes a general-purpose renderer.

The Server Response

// Full SDUI server response — a complete screen definition
{
  "screen": "home",
  "root": {
    "type": "ScrollView",
    "children": [
      {
        "type": "Column",
        "props": { "padding": 16, "spacing": 12 },
        "children": [
          {
            "type": "Text",
            "props": {
              "text": "Welcome back, Sarah",
              "style": "heading1"
            }
          },
          {
            "type": "Card",
            "props": { "elevation": 2 },
            "children": [
              {
                "type": "Image",
                "props": { "url": "https://...", "aspectRatio": 1.5 }
              },
              {
                "type": "Button",
                "props": { "text": "Shop Now", "style": "primary" },
                "actions": {
                  "onTap": { "type": "navigate", "route": "/products/summer" }
                }
              }
            ]
          }
        ]
      }
    ]
  }
}

The Recursive Renderer (Kotlin)

@Composable
fun SDUIRenderer(
    node: UIComponent,
    registry: ComponentRegistry,
    actionHandler: ActionHandler
) {
    val renderer = registry.resolve(node.type)

    if (renderer != null) {
        renderer(node.props, node.children)
    } else {
        // Fallback: render children if we don't recognize the parent
        node.children.forEach { child ->
            SDUIRenderer(child, registry, actionHandler)
        }
    }
}

// Register layout containers that render children recursively
registry.register("Column") { props, children ->
    Column(
        modifier = Modifier
            .padding(props.dpOrDefault("padding", 0)),
        verticalArrangement = Arrangement.spacedBy(
            props.dpOrDefault("spacing", 0)
        )
    ) {
        children.forEach { child ->
            SDUIRenderer(child, registry, actionHandler)
        }
    }
}

registry.register("Row") { props, children ->
    Row(
        modifier = Modifier
            .padding(props.dpOrDefault("padding", 0)),
        horizontalArrangement = Arrangement.spacedBy(
            props.dpOrDefault("spacing", 0)
        )
    ) {
        children.forEach { child ->
            SDUIRenderer(child, registry, actionHandler)
        }
    }
}

Action System

// Actions connect UI events to behavior
class ActionHandler(
    private val navigator: Navigator,
    private val analytics: Analytics,
    private val apiClient: ApiClient
) {
    fun handle(action: Action) {
        when (action.type) {
            "navigate" -> navigator.navigateTo(action.route)
            "deeplink" -> navigator.openDeepLink(action.url)
            "api_call" -> apiClient.execute(action.endpoint, action.params)
            "track" -> analytics.track(action.event, action.properties)
            "set_state" -> stateManager.update(action.key, action.value)
        }
    }
}

What you gain: Any screen can be defined, modified, or replaced from the server. New features can ship without mobile releases. Personalization becomes trivial — send different component trees to different users.

What to watch for: At Level 3, testing complexity increases significantly. You need schema validation, contract tests between server and client, and snapshot testing for rendered output. Don't skip this.

8. Level 4: Zero-Release

Level 4 Zero-Release Operations

⏱ Timeline: 3-6 months (from Level 3) 👥 Team: 4-6 engineers ⚡ Impact: Transformational

Visual editing tools, built-in A/B testing, analytics integration, and role-based access. Non-engineers can create and modify screens without writing code or waiting for releases.

Level 4 is where SDUI goes from an engineering tool to an organizational capability. At this level, product managers can build screens, growth teams can run experiments, and marketing can update promotions — all without filing a ticket.

This is what why companies like Airbnb use SDUI's Ghost Platform, Lyft's Canvas, and DoorDash's Mosaic enable internally. It's also what takes the longest to build from scratch.

What Level 4 Looks Like

"The time it takes to build and roll out a server-driven experiment can be as few as a day or two, whereas client-driven experiments require a minimum of 2 weeks."

— Lyft Engineering

The honest truth: Building Level 4 yourself takes 6-12 months of dedicated platform engineering. That's why most companies that achieve this level either have massive platform teams (Airbnb, Netflix) or use a managed platform.

9. Common Migration Pitfalls

We've seen teams fail at SDUI migration in predictable ways. Here are the patterns to avoid:

❌ Pitfall 1: Trying to Migrate Everything at Once

The "big bang" approach — converting every screen to SDUI simultaneously — almost always fails. It creates a massive blast radius, makes debugging impossible, and burns team goodwill.

Instead: Start with one screen. Prove it works. Expand incrementally. Run SDUI and traditional screens side by side — they coexist fine.

❌ Pitfall 2: No Fallback Strategy

Server-driven means server-dependent. If your API is down and you have no fallback, your users see a blank screen. This is worse than hardcoded UI.

Instead: Always ship a bundled fallback for critical screens. Cache the last successful response. Implement graceful degradation for unknown component types.

❌ Pitfall 3: Ignoring Offline Support

Many SDUI implementations work great with network and break completely without it. If your users ever have spotty connectivity (subway, rural areas, flights), this is a deal-breaker.

Instead: Cache UI definitions aggressively. Use stale-while-revalidate patterns. For critical flows (checkout, authentication), have hardcoded fallbacks.

❌ Pitfall 4: Over-Engineering the Schema

Teams spend months designing the "perfect" schema before rendering a single component. Then they discover it doesn't work for real screens.

Instead: Start with 5-10 component types. Build them for your pilot screen. Iterate the schema based on real usage, not theoretical completeness.

❌ Pitfall 5: Backend Team Not On Board

SDUI shifts significant responsibility to the backend. If backend engineers see it as "doing mobile's job" and push back, adoption stalls.

Instead: Involve backend from day one. Frame it as a platform investment, not a mobile convenience feature. The backend team needs to own the SDUI API as a product.

❌ Pitfall 6: No Versioning Strategy

You ship a new component type but users on old app versions don't know how to render it. Without versioning, every schema change risks breaking older clients.

Instead: Include client version in every request. Let the server respond with components the client can actually render. Unknown types should degrade gracefully (skip, show placeholder), never crash.

10. Real-World Migration Examples

Let's look at how real companies approached their SDUI migrations and what they learned.

Faire Eliminated 90% of rendering logic
Airbnb Ghost Platform — entire app
DoorDash Mosaic — merchant + consumer
Nubank Catalyst — 115M users, Flutter

Faire: 90% Less Rendering Code

Faire, the wholesale marketplace, migrated to SDUI and reported one of the most dramatic results in the industry: they eliminated approximately 90% of their client-side rendering logic.

Their approach was methodical:

  1. Started with their product browse experience — a high-change surface
  2. Built a component library mapped to their existing design system
  3. Gradually moved screens from hardcoded to server-driven
  4. Backend teams could now ship UI changes independently of mobile releases

The key lesson from Faire: SDUI doesn't add complexity — it moves it. The rendering logic didn't disappear; it moved to the server where it was easier to iterate, test, and deploy.

Airbnb: Ghost Platform

Airbnb's Ghost Platform is the most well-documented SDUI migration in the industry. Their approach started in 2017 and evolved over several years:

"What if clients didn't need to know they were even displaying a listing? What if we could pass the UI directly to the client and skip the idea of listing data entirely?"

— Ryan Brooks, Airbnb

Airbnb's biggest insight: SDUI is most valuable where business logic intersects with UI presentation. A listing looks different in search results vs. wishlist vs. booking confirmation. Instead of encoding that logic on every client, they centralized it on the server.

DoorDash: Mosaic

DoorDash built Mosaic to power both their consumer and merchant apps. Their migration strategy focused on:

DoorDash's key lesson: make adoption optional and easy. Teams that were forced to adopt SDUI resisted; teams that could choose to adopt it became advocates.

11. How Pyramid Accelerates Migration

The companies above spent years and dozens of engineers building their SDUI platforms. That's realistic for a company with 500+ engineers and a dedicated platform team. For everyone else, it's not feasible.

Pyramid was built to give every mobile team the same capabilities without the multi-year investment. Here's how:

BYOC: Bring Your Own Components

This is the critical difference. Pyramid doesn't replace your design system — it renders your existing components server-side.

// Your existing Compose component — unchanged
@Composable
fun ProductCard(
    title: String,
    price: String,
    imageUrl: String,
    onTap: () -> Unit
) {
    // Your existing implementation stays exactly the same
    Card(onClick = onTap) {
        AsyncImage(model = imageUrl)
        Text(title, style = MaterialTheme.typography.titleMedium)
        Text(price, style = MaterialTheme.typography.bodyLarge)
    }
}

// Register it with Pyramid — one line
pyramid.register("ProductCard", ::ProductCard)

Your components. Your design system. Your codebase. Pyramid just lets the server compose them.

What Pyramid Gives You

Migration with Pyramid

With Pyramid, you can skip straight to Level 2 or Level 3 in weeks instead of months:

  1. Week 1: Install SDK, register 5-10 existing components
  2. Week 2: Build your pilot screen in the visual editor
  3. Week 3: A/B test the server-driven version against the hardcoded version
  4. Week 4: Roll out, monitor, and start the next screen

No custom schema design. No building a visual editor from scratch. No spending 6 months on infrastructure before seeing value.

12. Timeline Estimates

Here's what realistic timelines look like, based on a mid-sized mobile team (4-8 engineers) building from scratch vs. using a platform like Pyramid:

Level DIY (From Scratch) With Pyramid Value Delivered
Level 0 — Config Flags 1-2 weeks N/A (skip) Feature toggles, simple A/B
Level 1 — Dynamic Content 2-4 weeks 1 week Server-controlled copy & images
Level 2 — Dynamic Layouts 4-8 weeks 2 weeks Server-composed screens
Level 3 — Full SDUI 2-4 months 3-4 weeks Complete server-driven screens
Level 4 — Zero-Release 6-12 months 4-6 weeks Visual editor, A/B, no-code
📊 The real timeline factor
The biggest variable isn't technical complexity — it's organizational alignment. Getting backend teams, product managers, and designers to buy into the new workflow takes longer than writing the code. Start socializing early.

Recommended Migration Pace

Don't rush. A well-executed Level 2 migration delivers more value than a rushed Level 4 that nobody trusts.

13. Getting Started

Here's a practical checklist for starting your SDUI migration today:

1
Audit your screens. Open your git history. Find the screens with the most commits in the last 6 months. Those are your migration candidates.
2
Pick your pilot. Choose one screen. Ideal: high-change, content-heavy, not performance-critical. Home feeds, onboarding, and promotional surfaces are great first picks.
3
Inventory your components. List every reusable component in that screen. These become your SDUI component registry. If you don't have reusable components yet, componentize first.
4
Decide: build or buy. If you have a dedicated platform team and 6+ months of runway, building makes sense. If you need value in weeks, use a platform like Pyramid.
5
Ship and iterate. Deploy the server-driven version alongside the hardcoded one. A/B test. Measure render times, error rates, and team velocity. Expand based on data, not hope.

Ready to start your SDUI migration?

Pyramid gives you a production-ready SDUI platform with native SDKs, a visual editor, and A/B testing — without months of infrastructure work. Keep your components. ship faster.

Get Early Access →

Related Articles

Conclusion

Migrating to server-driven UI isn't a weekend project, but it doesn't have to be a multi-year odyssey either. The key is thinking in levels — start with config flags, progress to dynamic content, then dynamic layouts, and eventually full SDUI. Each level delivers value on its own.

The companies that have done this successfully — Faire, Airbnb, DoorDash, Lyft — all share one insight: migrate incrementally, prove value early, and expand based on results. None of them rewrote their entire app on day one.

Whether you build your SDUI system from scratch or use a platform like Pyramid, the important thing is to start. Pick one screen. Register your components. Let the server drive it. See what happens.

Your web team has been shipping at this speed for years. It's time your mobile team caught up.