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.
In This Article
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:
- Design systems answer: "What does a button look like? What spacing do we use? What's our color palette?"
- SDUI answers: "Which button appears on this screen? What does it say? What happens when you tap it?"
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.
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:
- Component mapping: Every SDUI component type maps to a Lego component. The SDUI layer never renders its own UI — it always delegates to Lego.
- Token-driven styling: Layout responses reference Lego design tokens by name. Colors, typography, and spacing are always token references, never raw values.
- Constrained composition: The server can compose Lego components into layouts, but it can't create new visual patterns. If the composition isn't in the design system, the server can't produce it.
- Storefront flexibility: DoorDash uses SDUI to dynamically arrange their storefront pages — different merchants get different layouts, but all using the same Lego components.
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:
- Section-based SDUI: Airbnb doesn't render entire screens through SDUI. They use a section-based model where the server defines which sections appear on a page and in what order. Each section is a DLS component composition.
- Typed section contracts: Each section has a strongly typed schema that maps directly to DLS component APIs. The server can't send arbitrary props — only what the DLS exposes.
- Ghost Platform: Airbnb's internal platform (Ghost) handles the server-driven orchestration while DLS handles the rendering. The separation of concerns is clean: Ghost decides what, DLS decides how.
- Incremental adoption: They didn't convert their entire app to SDUI overnight. They started with the most dynamic pages (search results, listing details) and expanded gradually.
✅ 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:
- Home/discovery feeds — Content changes constantly, layout experiments are common
- Promotional screens — Campaigns and seasonal content rotate frequently
- Onboarding flows — Product teams want to iterate without releases
- Settings/profile pages — Feature flags frequently show/hide sections
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:
- 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.
- 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.
- Auto-generate adapters. Pyramid generates the BYOC adapter code — the mapping layer between SDUI JSON props and your component APIs. No manual
whenstatements, no manual prop parsing. - 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
- SDUI and design systems complement each other. Design systems define how things look. SDUI defines what appears and when. They operate at different layers.
- BYOC is the only scalable approach. Any SDUI system that ships its own components will create design debt. Map your existing components instead.
- Send token references, not raw values.
$color.brand.primaryrespects your design system.#6366f1bypasses it. - Generate schemas from your component APIs. Manual schema maintenance drifts. Auto-generation keeps schemas and components in sync.
- Adopt incrementally. Start with one high-value screen. Register the components it needs. Expand from there.
- DoorDash and Airbnb prove the pattern. Both built SDUI on top of their existing design systems, not as a replacement. It works at massive scale.