← Back to Blog

April 3, 2026 · 12 min read

SDUI and Design Systems: Building Server-Driven UI on Your Component Library

Your team spent years building a design system. SDUI doesn't mean throwing it away — it means making it server-controllable. Here's how to bridge the two without compromise.

The Tension: Design Systems vs. SDUI

If you've invested in a design system — a shared component library with tokens, constraints, and documented patterns — the idea of server-driven UI probably triggers a specific fear: "Will SDUI replace the components we spent two years building?"

It's a fair concern. Most SDUI frameworks ship their own component set. They give you a SDUIButton and a SDUICard that look nothing like yours. Your design team hates them. Your accessibility layer doesn't apply. Your theming breaks. Suddenly you're maintaining two UI systems — your design system and the SDUI one — and they're drifting apart.

This is the wrong framing. Design systems and SDUI aren't competing concepts. They operate at different layers:

One defines the vocabulary. The other writes the sentences. They're complementary — but only if your SDUI system is designed to respect the constraints your design system already enforces.

Design System + SDUI: Complementary Layers ┌──────────────────────────────────────────────────┐ │ SDUI Layer │ │ "Show a primary button labeled 'Add to Cart' │ │ below the product image, in a vertical stack" │ │ │ │ Controls: WHAT appears, WHERE, and WHEN │ └──────────────────┬───────────────────────────────┘ │ maps to ┌──────────────────▼───────────────────────────────┐ │ Design System Layer │ │ Button(style: .primary, label: "Add to Cart") │ │ → corner radius: 8dp │ │ → font: Inter Semibold 16 │ │ → color: brand.primary (#6366f1) │ │ → padding: 16dp horizontal, 12dp vertical │ │ │ │ Controls: HOW it looks and behaves │ └──────────────────────────────────────────────────┘

The question isn't whether to use SDUI or your design system. It's how to use SDUI through your design system. That's where BYOC comes in.

The BYOC Paradigm: Keep Your Components

BYOC — Bring Your Own Components — is the architecture pattern where the SDUI framework handles orchestration (fetching layouts, resolving schemas, managing state) while your components handle rendering. The framework never draws a pixel. Your design system does.

Here's how it works. Instead of the SDUI SDK providing a ButtonComponent that renders its own UI, you register your design system's button and tell the framework how to map SDUI props to your component's API:

// Kotlin — Registering your design system's button with SDUI
class DSButtonComponent : SDUIComponent {
    override val type = "button"

    @Composable
    override fun Render(props: JsonObject, children: List<JsonNode>) {
        // Map SDUI props → your design system's API
        val variant = when (props["style"]?.string) {
            "primary" -> DSButtonVariant.Primary
            "secondary" -> DSButtonVariant.Secondary
            "ghost" -> DSButtonVariant.Ghost
            else -> DSButtonVariant.Primary
        }
        val size = when (props["size"]?.string) {
            "small" -> DSButtonSize.Small
            "large" -> DSButtonSize.Large
            else -> DSButtonSize.Medium
        }

        // Render YOUR button, not a generic SDUI button
        DSButton(
            label = props["label"]!!.string,
            variant = variant,
            size = size,
            enabled = props["disabled"]?.boolean != true,
            onClick = { actionHandler.handle(props["action"]) }
        )
    }
}
// Swift — Registering your design system's button with SDUI
struct DSButtonComponent: SDUIComponent {
    let type = "button"

    func render(props: [String: Any], children: [JSONNode]) -> some View {
        let variant: DSButton.Variant = {
            switch props["style"] as? String {
            case "primary": return .primary
            case "secondary": return .secondary
            case "ghost": return .ghost
            default: return .primary
            }
        }()

        // Render YOUR SwiftUI button component
        DSButton(
            label: props["label"] as! String,
            variant: variant,
            size: .medium
        ) {
            actionHandler.handle(props["action"])
        }
    }
}

The SDUI JSON for this screen might look like:

{
    "type": "button",
    "properties": {
        "label": "Add to Cart",
        "style": "primary",
        "size": "large",
        "action": {
            "type": "http",
            "url": "/api/cart/add",
            "method": "POST"
        }
    }
}

The server controls what button appears and what it does. Your design system controls how it looks. Nobody draws a pixel that isn't in your Figma file.

✅ The BYOC Guarantee

With BYOC, every screen rendered through SDUI is indistinguishable from a screen built by hand. Same components, same tokens, same accessibility attributes. Your design team can't tell the difference — and that's the point.

Design Tokens Over the Wire

Design systems don't just define components — they define tokens: colors, typography scales, spacing units, corner radii, elevation levels. These tokens are the atomic building blocks that keep your UI consistent.

The question is: when the server drives the UI, who owns the tokens? The answer should be both. The client owns the token definitions (what brand.primary resolves to), but the server can reference tokens by name in layouts.

Token References in SDUI Schemas

Instead of sending raw hex colors in your SDUI JSON (which would bypass your design system), send token references:

// ❌ Bad: Raw values bypass design system
{
    "type": "container",
    "properties": {
        "backgroundColor": "#6366f1",
        "padding": 16,
        "cornerRadius": 8
    }
}

// ✅ Good: Token references respect design system
{
    "type": "container",
    "properties": {
        "backgroundColor": "$color.surface.brand",
        "padding": "$spacing.md",
        "cornerRadius": "$radius.lg"
    }
}

The client resolves $color.surface.brand to whatever your design system defines — and that resolution automatically handles dark mode, high-contrast accessibility, and brand theming.

Token Resolution Engine

// Kotlin — Design token resolver
class DesignTokenResolver(
    private val tokens: DesignTokenStore
) {
    private val tokenPattern = Regex("""\$([a-zA-Z0-9_.]+)""")

    fun resolveColor(value: String): Color {
        if (!value.startsWith("$")) {
            // Raw value — log a warning in debug builds
            return Color.parse(value)
        }
        val tokenPath = value.removePrefix("$")
        return tokens.color(tokenPath)
            ?: throw UnknownTokenException("Color token not found: $tokenPath")
    }

    fun resolveSpacing(value: String): Dp {
        if (!value.startsWith("$")) return value.toInt().dp
        val tokenPath = value.removePrefix("$")
        return tokens.spacing(tokenPath)
            ?: throw UnknownTokenException("Spacing token not found: $tokenPath")
    }

    fun resolveTypography(value: String): TextStyle {
        val tokenPath = value.removePrefix("$")
        return tokens.typography(tokenPath)
            ?: MaterialTheme.typography.bodyMedium
    }
}
// Swift — Design token resolver
class DesignTokenResolver {
    private let tokens: DesignTokenStore

    func resolveColor(_ value: String) -> Color {
        guard value.hasPrefix("$") else {
            return Color(hex: value)
        }
        let path = String(value.dropFirst())
        return tokens.color(path) ?? .primary
    }

    func resolveSpacing(_ value: String) -> CGFloat {
        guard value.hasPrefix("$") else {
            return CGFloat(Int(value) ?? 0)
        }
        let path = String(value.dropFirst())
        return tokens.spacing(path) ?? 16
    }

    func resolveFont(_ value: String) -> Font {
        let path = String(value.dropFirst())
        return tokens.font(path) ?? .body
    }
}

Server-Side Theme Overrides

Sometimes the server needs to send a complete theme — for a seasonal campaign, a partner co-brand, or a regional variant. Instead of sending individual token values, send a theme object that the client merges with its base tokens:

{
    "layout": { "type": "screen", "children": [...] },
    "themeOverrides": {
        "color.surface.brand": "#dc2626",
        "color.text.onBrand": "#ffffff",
        "color.action.primary": "#dc2626"
    }
}

The client applies these overrides for the duration of that screen, then reverts to the base design system tokens on navigation. This gives the server per-screen theming power without permanently mutating the client's design system.

⚠️ Don't Send Raw Values in Component Props

If your SDUI JSON includes "color": "#ff5722" inside a component's props, that value bypasses every design system constraint. Token references ("color": "$color.accent") ensure the design system stays in control. Reserve raw values for theme overrides only, where they're applied through the token system, not around it.

Component Contract Patterns

A design system isn't just components — it's constraints. Your button doesn't accept any color; it accepts primary, secondary, or ghost. Your spacing isn't arbitrary pixels; it's a scale of xs, sm, md, lg, xl. A good SDUI schema should enforce those same constraints.

Constrained Schemas

Instead of accepting arbitrary strings, define schemas that mirror your design system's API:

// Schema: constrained to design system values
{
    "$id": "button/v1",
    "properties": {
        "label": { "type": "string", "maxLength": 40 },
        "style": {
            "type": "string",
            "enum": ["primary", "secondary", "ghost", "destructive"]
        },
        "size": {
            "type": "string",
            "enum": ["small", "medium", "large"]
        },
        "icon": {
            "type": "string",
            "enum": ["cart", "heart", "share", "chevron_right"]
        }
    }
}

Notice the constraints: style is an enum with four valid values — exactly the four variants your design system supports. icon is an enum of named icons from your icon set. The schema doesn't allow "style": "big-red-flashy" because your design system doesn't have that variant.

Generating Schemas from Your Design System

The most robust approach is to generate SDUI schemas directly from your design system's source code. If your Kotlin design system defines an enum of button variants, your schema should be auto-generated from it:

// Kotlin — Your design system's button variants
enum class DSButtonVariant {
    Primary, Secondary, Ghost, Destructive
}

// Generated SDUI schema constraint:
// "enum": ["primary", "secondary", "ghost", "destructive"]

// If a developer adds a variant to the design system:
enum class DSButtonVariant {
    Primary, Secondary, Ghost, Destructive, Outline  // new!
}

// The schema generator picks it up automatically:
// "enum": ["primary", "secondary", "ghost", "destructive", "outline"]

This eliminates the most common source of SDUI drift: the design system evolves, but the SDUI schemas don't. With generated schemas, they evolve together.

💡 Schema = Design System API Surface

Think of your SDUI schema as the serialization format for your design system's public API. Every prop in the schema should map 1:1 to a parameter your component accepts. If it's not in the design system, it shouldn't be in the schema.

Real-World Examples: DoorDash and Airbnb

Two companies that have publicly documented their SDUI + design system integration offer instructive patterns.

DoorDash: The Lego System

DoorDash's SDUI implementation is built directly on top of their internal design system, called Lego. Their approach demonstrates BYOC at scale:

The result: DoorDash's product teams can rearrange screens, run experiments, and personalize layouts without ever breaking design consistency. The Lego design system is the constraint layer, and SDUI operates within those constraints.

Airbnb: SDUI on the Design Language System (DLS)

Airbnb's approach is well-documented in their engineering blog posts about server-driven rendering. Their design system — the Design Language System (DLS) — predates their SDUI adoption, and the team was explicit about not replacing it:

✅ Pattern: Both DoorDash and Airbnb Agree

Neither company built a separate set of "SDUI components." Both use their existing design system as the rendering layer and SDUI as the orchestration layer. This is the BYOC pattern in practice — and it's the only approach that scales without creating design debt.

Migration Path: Incremental Adoption

You don't need to convert your entire app to SDUI in one sprint. The best migration strategy is incremental: start with the screens that benefit most, and expand from there.

Phase 1: Identify High-Value Screens

Start with screens that change frequently, are the target of A/B tests, or require backend team involvement for layout changes. Common starting points:

Phase 2: Register Your Design System Components

For each component your SDUI screens need, create a BYOC adapter that maps SDUI props to your design system's API:

// Kotlin — Adapter registry for your design system
fun registerDesignSystem(registry: ComponentRegistry) {
    // Core primitives
    registry.register(DSTextAdapter())       // maps to DSText
    registry.register(DSButtonAdapter())     // maps to DSButton
    registry.register(DSImageAdapter())      // maps to DSImage
    registry.register(DSCardAdapter())       // maps to DSCard
    registry.register(DSDividerAdapter())    // maps to DSDivider

    // Layout components
    registry.register(DSStackAdapter())      // maps to DSColumn/DSRow
    registry.register(DSGridAdapter())       // maps to DSGrid
    registry.register(DSListAdapter())       // maps to DSLazyList

    // Domain components
    registry.register(ProductCardAdapter())  // maps to ProductCard
    registry.register(UserAvatarAdapter())   // maps to UserAvatar
    registry.register(RatingBadgeAdapter())  // maps to RatingBadge
}

Phase 3: Hybrid Screens

You don't have to make a screen fully server-driven. A hybrid approach renders the static shell natively and injects SDUI sections where dynamism is needed:

// Kotlin — Hybrid screen: static shell + SDUI sections
@Composable
fun HomeScreen(
    viewModel: HomeViewModel,
    sduiRenderer: SDUIRenderer
) {
    Scaffold(
        // Static: native navigation bar
        topBar = { DSTopBar(title = "Home") },
        // Static: native bottom navigation
        bottomBar = { DSBottomNav() }
    ) { padding ->
        LazyColumn(modifier = Modifier.padding(padding)) {
            // Static: always-present search bar
            item { SearchBar(onSearch = viewModel::search) }

            // Dynamic: SDUI-driven content sections
            val layout = viewModel.sduiLayout
            layout?.children?.forEach { section ->
                item {
                    sduiRenderer.render(section)
                }
            }

            // Static: always-present footer
            item { HomeFooter() }
        }
    }
}
// Swift — Hybrid screen: static shell + SDUI sections
struct HomeScreen: View {
    @StateObject var viewModel: HomeViewModel
    let renderer: SDUIRenderer

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 0) {
                    // Static: always-present search
                    SearchBar(onSearch: viewModel.search)

                    // Dynamic: SDUI-driven sections
                    if let layout = viewModel.sduiLayout {
                        ForEach(layout.children, id: \.id) { section in
                            renderer.render(section)
                        }
                    }

                    // Static: always-present footer
                    HomeFooter()
                }
            }
            .navigationTitle("Home")
        }
    }
}

Phase 4: Expand and Refine

As your team gains confidence, expand SDUI to more screens. Track two metrics to guide expansion:

Metric What It Tells You Target
Unknown component rate How often the server sends types the client can't render < 1%
Design system coverage % of your design system components registered as BYOC adapters > 80% of used components
Layout fetch latency Time to fetch and render an SDUI layout vs. static screen Within 100ms of static
Experiment velocity How many layout experiments run per week Increasing month-over-month

💡 Start with One Screen, Not One Component

Teams often try to "SDUI-ify" individual components. That's backwards. Pick a whole screen (or section), register the 5-10 components it needs, and make that screen server-driven end-to-end. You'll learn more from one real SDUI screen than from 20 SDUI-wrapped components sitting unused.

How Pyramid Handles the Mapping

Everything we've described — BYOC registration, token resolution, constrained schemas, hybrid rendering — is what Pyramid's BYOC compiler automates.

Here's how it works:

  1. Point Pyramid at your design system. The BYOC compiler scans your component library (Jetpack Compose components, SwiftUI views) and extracts the public API: parameter names, types, enums, defaults.
  2. Auto-generate SDUI schemas. From your component APIs, Pyramid generates constrained JSON schemas. Your button has four variants? The schema enforces exactly four values. Your spacing uses a token scale? The schema references those tokens.
  3. Auto-generate adapters. Pyramid generates the BYOC adapter code — the mapping layer between SDUI JSON props and your component APIs. No manual when statements, no manual prop parsing.
  4. Register at startup. A single Pyramid.registerAll() call registers every generated adapter with the component registry.
// Kotlin — Pyramid BYOC setup (3 lines)
val pyramid = Pyramid.builder()
    .designSystem(MyDesignSystem.components())  // scan your DS
    .tokenResolver(MyDesignSystem.tokens())     // wire up tokens
    .build()

// Every component in your design system is now SDUI-ready
pyramid.render(layoutFromServer)
// Swift — Pyramid BYOC setup
let pyramid = Pyramid.Builder()
    .designSystem(MyDesignSystem.components())
    .tokenResolver(MyDesignSystem.tokens())
    .build()

// Your entire design system, server-controllable
PyramidView(layout: layoutFromServer)

The result: your design system becomes server-controllable in days, not months. No rebuilding components. No maintaining two UI systems. No design drift.

Your Design System, Server-Driven

Pyramid's BYOC compiler maps your existing components to SDUI schemas automatically. Keep your design system. Gain server-side control. No compromises.

Join the Waitlist →

Key Takeaways

Further Reading

Related Articles