← Back to Blog

March 17, 2026 · 18 min read

SDUI Architecture Patterns: Building Scalable Server-Driven UI Systems

Building a server-driven UI system that survives production is about architecture, not just rendering JSON. Here are the 8 patterns that separate toy demos from production-grade SDUI.

Overview: Why SDUI Architecture Matters

Every mobile team that builds a server-driven UI system faces the same inflection point: the first version works for one screen. Then you need a second screen. Then conditional logic. Then A/B testing. Then backward compatibility with three app versions in the wild.

This is where most SDUI implementations break. Not because the rendering engine fails — that part is usually straightforward. They break because the architecture wasn't designed for scale.

A well-architected SDUI system needs to answer several fundamental questions:

In this guide, we'll walk through 8 architecture patterns that address these questions. Each pattern includes real code examples in Kotlin and Swift, architecture diagrams, and guidance on when to apply them.

SDUI System Architecture — High-Level Request Flow ┌──────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ │ Mobile │────▶│ API / │────▶│ Layout │────▶│ Component │ │ Client │ │ Gateway │ │ Service │ │ Registry │ └──────────┘ └──────────────┘ └───────────────┘ └──────────────┘ │ │ │ │ ┌──────────────┐ │ │ │ │ Experiment │◀───────────┘ │ │ │ Service │ │ │ └──────────────┘ │ │ │ ▼ ▼ ┌──────────┐ ┌──────────────┐ │ Render │◀──────────────────────────────────────────────│ Schema │ │ Engine │ │ Validator │ └──────────┘ └──────────────┘

Let's dive into each pattern.

Pattern 1: Component Registry

The Component Registry is the foundation of every SDUI system. It's the lookup table that maps a component type string from your JSON (like "text" or "product_card") to a native UI implementation on the client.

The pattern has three phases: register, discover, and render.

Register

At app startup, native components register themselves with the registry. This is where you declare which JSON types your client can handle:

// Kotlin — Component Registration
interface SDUIComponent {
    val type: String
    @Composable
    fun Render(props: JsonObject, children: List<JsonNode>)
}

class ComponentRegistry {
    private val components = mutableMapOf<String, SDUIComponent>()
    private var fallback: SDUIComponent? = null

    fun register(component: SDUIComponent) {
        components[component.type] = component
    }

    fun registerFallback(component: SDUIComponent) {
        fallback = component
    }

    fun resolve(type: String): SDUIComponent {
        return components[type] ?: fallback
            ?: throw UnknownComponentException(type)
    }

    fun supportedTypes(): Set<String> = components.keys
}
// Swift — Component Registration
protocol SDUIComponent {
    var type: String { get }
    @ViewBuilder
    func render(props: [String: Any], children: [JSONNode]) -> some View
}

class ComponentRegistry {
    private var components: [String: any SDUIComponent] = [:]
    private var fallback: (any SDUIComponent)?

    func register(_ component: any SDUIComponent) {
        components[component.type] = component
    }

    func resolve(_ type: String) -> any SDUIComponent {
        return components[type] ?? fallback ?? PlaceholderComponent()
    }
}

Discover

When a layout JSON arrives, the render engine walks the component tree and asks the registry: "Do you know how to render this type?" This is the discovery phase.

Component Resolution Flow JSON Node: { "type": "product_card", "props": {...} } │ ▼ ┌────────────────┐ │ Registry │ │ .resolve() │ └────────┬───────┘ │ ┌───────┴───────┐ │ │ Found? Not Found? │ │ ▼ ▼ ┌──────────┐ ┌──────────────┐ │ Render │ │ Fallback │ │ Native │ │ Component │ │ View │ │ (or skip) │ └──────────┘ └──────────────┘

Render

The resolved component receives its props and children, then renders a fully native view. No WebViews, no interpretation — just native platform UI:

// Kotlin — Recursive Rendering
@Composable
fun SDUIRenderer(
    node: JsonNode,
    registry: ComponentRegistry
) {
    val component = registry.resolve(node.type)
    
    component.Render(
        props = node.properties,
        children = node.children
    )
}

// Usage: the render engine walks the tree recursively
@Composable
fun SDUIScreen(layout: Layout, registry: ComponentRegistry) {
    layout.rootNodes.forEach { node ->
        SDUIRenderer(node, registry)
    }
}

💡 Registry Best Practice

Keep your registry flat. Don't nest registries or use inheritance hierarchies — a simple Map<String, Component> is faster to look up, easier to debug, and simpler to extend. Most production systems have 30-80 registered component types.

Pattern 2: Schema-First Design

Without a schema, your SDUI system is a JSON guessing game. The server sends whatever it wants, the client parses whatever it can, and bugs hide in the gap between the two.

Schema-first design means you define your component contracts in a formal schema (JSON Schema, Protocol Buffers, or a custom DSL) before writing any rendering code. Then you generate types, validators, and documentation from that single source of truth.

Define Components via Schema

// JSON Schema for a Button component
{
    "$id": "https://pyramidui.com/schemas/button/v1",
    "type": "object",
    "required": ["type", "properties"],
    "properties": {
        "type": { "const": "button" },
        "properties": {
            "type": "object",
            "required": ["label"],
            "properties": {
                "label": { "type": "string" },
                "style": {
                    "type": "string",
                    "enum": ["primary", "secondary", "ghost"],
                    "default": "primary"
                },
                "disabled": { "type": "boolean", "default": false },
                "action": { "$ref": "#/definitions/Action" }
            }
        }
    }
}

Generate Code from Schema

From this single schema definition, you can generate:

// Kotlin — Generated from schema
@Serializable
data class ButtonProps(
    val label: String,
    val style: ButtonStyle = ButtonStyle.PRIMARY,
    val disabled: Boolean = false,
    val action: Action? = null
)

enum class ButtonStyle {
    @SerialName("primary") PRIMARY,
    @SerialName("secondary") SECONDARY,
    @SerialName("ghost") GHOST
}
// Swift — Generated from schema
struct ButtonProps: Codable {
    let label: String
    var style: ButtonStyle = .primary
    var disabled: Bool = false
    var action: Action? = nil
}

enum ButtonStyle: String, Codable {
    case primary, secondary, ghost
}

✅ Why Schema-First Wins

Schema-first design eliminates an entire category of bugs: "the server sent X but the client expected Y." With generated types, these mismatches become compile-time errors instead of runtime crashes.

Schema Validation Pipeline

Validate layouts at multiple points in the pipeline:

Validation Point What It Catches When It Runs
CI/CD Schema changes that break existing clients On every schema PR
Server-side Invalid layouts before they reach clients On layout publish
Client-side Corrupted or tampered responses On JSON parse

Pattern 3: Action System

A server-driven UI without actions is just a fancy static page. The action system defines how users interact with SDUI screens: tapping buttons, submitting forms, navigating between screens, and triggering API calls.

A well-designed action system covers four categories: navigation, HTTP calls, state mutations, and event handling.

Action Types

// Kotlin — Action model
@Serializable
sealed class Action {
    // Navigate to another screen or URL
    @Serializable
    @SerialName("navigate")
    data class Navigate(
        val destination: String,
        val params: Map<String, String> = emptyMap(),
        val transition: Transition = Transition.PUSH
    ) : Action()
    
    // Make an HTTP request
    @Serializable
    @SerialName("http")
    data class Http(
        val url: String,
        val method: HttpMethod = HttpMethod.POST,
        val body: JsonObject? = null,
        val onSuccess: Action? = null,
        val onError: Action? = null
    ) : Action()
    
    // Mutate local state
    @Serializable
    @SerialName("setState")
    data class SetState(
        val key: String,
        val value: JsonElement
    ) : Action()
    
    // Fire an analytics or custom event
    @Serializable
    @SerialName("event")
    data class Event(
        val name: String,
        val properties: Map<String, String> = emptyMap()
    ) : Action()
    
    // Chain multiple actions sequentially
    @Serializable
    @SerialName("sequence")
    data class Sequence(
        val actions: List<Action>
    ) : Action()
}

Action Handler

// Kotlin — Action execution engine
class ActionHandler(
    private val navigator: Navigator,
    private val httpClient: HttpClient,
    private val stateStore: StateStore,
    private val analytics: Analytics
) {
    suspend fun handle(action: Action) {
        when (action) {
            is Action.Navigate -> {
                navigator.navigate(action.destination, action.params)
            }
            is Action.Http -> {
                try {
                    httpClient.request(action.url, action.method, action.body)
                    action.onSuccess?.let { handle(it) }
                } catch (e: Exception) {
                    action.onError?.let { handle(it) }
                }
            }
            is Action.SetState -> {
                stateStore.set(action.key, action.value)
            }
            is Action.Event -> {
                analytics.track(action.name, action.properties)
            }
            is Action.Sequence -> {
                action.actions.forEach { handle(it) }
            }
        }
    }
}

Action Composition in JSON

Actions compose naturally. Here's a "Add to Cart" button that fires an analytics event, makes an API call, and navigates on success:

{
    "type": "button",
    "properties": {
        "label": "Add to Cart",
        "action": {
            "type": "sequence",
            "actions": [
                {
                    "type": "event",
                    "name": "add_to_cart_tapped",
                    "properties": { "productId": "{{product.id}}" }
                },
                {
                    "type": "http",
                    "url": "/api/cart/add",
                    "method": "POST",
                    "body": { "productId": "{{product.id}}" },
                    "onSuccess": {
                        "type": "navigate",
                        "destination": "/cart"
                    }
                }
            ]
        }
    }
}

⚠️ Security Consideration

Never allow arbitrary URL execution from server-driven actions. Maintain an allowlist of permitted hosts and URL patterns. Validate every HTTP action against it before execution. See our SDUI Security Best Practices guide.

Pattern 4: Data Binding & State Management

Static layouts are easy. The real challenge is making SDUI screens dynamic: showing the user's name, updating a counter, toggling visibility based on state. This requires a data binding system that connects server-defined expressions to client-side state.

Variables & Expressions

Use a simple expression syntax (like {{variable}}) to bind data into layouts:

// JSON layout with data binding
{
    "type": "screen",
    "variables": {
        "user": { "source": "api", "url": "/api/user/me" },
        "cartCount": { "source": "local", "default": 0 }
    },
    "children": [
        {
            "type": "text",
            "properties": {
                "content": "Hello, {{user.firstName}}!"
            }
        },
        {
            "type": "badge",
            "properties": {
                "count": "{{cartCount}}",
                "visible": "{{cartCount > 0}}"
            }
        }
    ]
}

Expression Engine

// Kotlin — Expression evaluator
class ExpressionEngine(
    private val stateStore: StateStore
) {
    private val pattern = Regex("""\{\{(.+?)\}\}""")
    
    fun resolve(template: String): String {
        return pattern.replace(template) { match ->
            val expression = match.groupValues[1].trim()
            evaluate(expression).toString()
        }
    }
    
    fun resolveBoolean(template: String): Boolean {
        val expr = pattern.find(template)?.groupValues?.get(1)?.trim()
            ?: return template.toBoolean()
        return evaluate(expr) as? Boolean ?: false
    }
    
    private fun evaluate(expression: String): Any? {
        // Handle comparisons: "cartCount > 0"
        if (expression.contains(">")) {
            val (left, right) = expression.split(">").map { it.trim() }
            val leftVal = resolveVariable(left) as? Number ?: 0
            val rightVal = right.toIntOrNull() ?: 0
            return leftVal.toInt() > rightVal
        }
        
        // Handle dot notation: "user.firstName"
        return resolveVariable(expression)
    }
    
    private fun resolveVariable(path: String): Any? {
        return stateStore.get(path)
    }
}

Reactive Updates

When state changes, the UI should automatically reflect those changes. This is where reactive state management comes in:

// Kotlin — Reactive state store
class StateStore {
    private val state = mutableStateMapOf<String, Any?>()
    
    fun set(key: String, value: Any?) {
        state[key] = value
    }
    
    fun get(path: String): Any? {
        val parts = path.split(".")
        var current: Any? = state[parts.first()]
        
        for (part in parts.drop(1)) {
            current = (current as? Map<*, *>)?.get(part)
        }
        return current
    }
    
    // Compose automatically recomposes when state changes
    @Composable
    fun observe(path: String): State<Any?> {
        return remember { derivedStateOf { get(path) } }
    }
}
// Swift — Reactive state store
@Observable
class StateStore {
    private var state: [String: Any] = [:]
    
    func set(_ key: String, value: Any) {
        state[key] = value
    }
    
    func get(_ path: String) -> Any? {
        let parts = path.split(separator: ".")
        var current: Any? = state[String(parts.first!)]
        
        for part in parts.dropFirst() {
            current = (current as? [String: Any])?[String(part)]
        }
        return current
    }
}

💡 Keep Expressions Simple

Resist the temptation to build a full programming language in your expression engine. Stick to variable interpolation, simple comparisons, and boolean logic. If you need complex logic, move it to the server or to native code.

Pattern 5: BYOC vs Provided Components

One of the biggest architectural decisions in SDUI is: who provides the components? There are two approaches, and most production systems use a hybrid.

Provided Components (SDK Approach)

The SDUI framework ships with a pre-built library of components: buttons, text, images, lists, cards, forms. Teams use these directly.

Pros Cons
Instant setup — works out of the box Limited to what the SDK offers
Consistent behavior across apps May not match your design system
Built-in accessibility and theming Harder to customize deeply
Less client-side code to maintain Framework dependency for UI updates

BYOC — Bring Your Own Components

Teams register their own native components and map them to server types. The SDUI framework handles orchestration, but the actual UI is yours.

// Kotlin — BYOC Registration
// Your custom component, your design system
class BrandedProductCard : SDUIComponent {
    override val type = "product_card"
    
    @Composable
    override fun Render(props: JsonObject, children: List<JsonNode>) {
        // Your design system components
        MyDesignSystem.Card(
            elevation = MyDesignSystem.Elevation.Medium
        ) {
            ProductImage(url = props["imageUrl"].string)
            ProductTitle(text = props["title"].string)
            PriceTag(
                price = props["price"].double,
                currency = props["currency"].string
            )
        }
    }
}

// Register at app startup
registry.register(BrandedProductCard())
Pros Cons
Full control over look and feel More work to implement each component
Uses your existing design system Must handle accessibility yourself
No framework lock-in on UI layer Requires client-side code for new types
Pixel-perfect design compliance Slower to add new component types

The Hybrid Approach

The best production systems use both: a library of provided primitives (text, image, spacer, divider) plus BYOC for domain-specific components (product cards, user profiles, payment widgets).

// Register SDK primitives + custom components
fun setupRegistry(): ComponentRegistry {
    val registry = ComponentRegistry()
    
    // SDK-provided primitives
    registry.register(SDKText())
    registry.register(SDKImage())
    registry.register(SDKButton())
    registry.register(SDKColumn())
    registry.register(SDKRow())
    registry.register(SDKSpacer())
    
    // Your custom BYOC components
    registry.register(BrandedProductCard())
    registry.register(CustomCheckoutWidget())
    registry.register(LiveStreamPlayer())
    
    // Fallback for anything unknown
    registry.registerFallback(EmptyComponent())
    
    return registry
}

Pattern 6: Versioning & Backward Compatibility

Here's the reality of mobile: when you push a schema change to your SDUI backend, app version 3.2.0 (released today) will understand it. But app version 3.0.0 (released two months ago and still running on 15% of devices) might not. Versioning is how you handle this.

Schema Versioning Strategies

There are three main approaches:

1. URL-based versioning: The simplest — different endpoints for different schema versions.

GET /api/v1/layout/home   → Schema v1 (legacy clients)
GET /api/v2/layout/home   → Schema v2 (current clients)
GET /api/v3/layout/home   → Schema v3 (latest clients)

2. Header-based negotiation: More flexible — the client declares what it supports.

// Client sends supported schema version
GET /api/layout/home
X-Schema-Version: 2
X-Supported-Components: text,image,button,card,carousel

// Server responds with compatible layout

3. Embedded versioning: Each component carries its own version.

{
    "schemaVersion": 2,
    "children": [
        {
            "type": "carousel",
            "version": 3,
            "properties": { ... }
        }
    ]
}

Deprecation Lifecycle

Don't remove old component types overnight. Follow a structured deprecation lifecycle:

Component Deprecation Lifecycle Phase 1: ACTIVE Phase 2: DEPRECATED Phase 3: SUNSET Phase 4: REMOVED ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Component v1 │─────▶│ Component v1 │──────▶│ Component v1 │─────▶│ (gone) │ │ fully │ │ still works │ │ fallback │ │ │ │ supported │ │ warns in │ │ rendered │ │ v2 is the │ │ │ │ dev builds │ │ for old apps │ │ only version │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ Duration: ~2 release cycles ~2 release cycles After force update
// Kotlin — Version-aware component resolution
class VersionedRegistry {
    private val components = mutableMapOf<String, Map<Int, SDUIComponent>>()
    
    fun register(type: String, version: Int, component: SDUIComponent) {
        val versions = components.getOrPut(type) { mutableMapOf() }
        (versions as MutableMap)[version] = component
    }
    
    fun resolve(type: String, requestedVersion: Int): SDUIComponent? {
        val versions = components[type] ?: return null
        
        // Try exact version, then fall back to latest compatible
        return versions[requestedVersion]
            ?: versions.filterKeys { it <= requestedVersion }
                .maxByOrNull { it.key }?.value
    }
}

⚠️ Rule of Thumb

Support at least the last 3 major app versions (or ~6 months of releases). Beyond that, prompt users to update. Track version distribution in analytics to know when it's safe to drop old schema versions.

Pattern 7: Fallback & Error Handling

In production, things go wrong. The server sends a component type your client doesn't know. The network drops mid-response. A layout references an image that 404s. Your SDUI architecture must handle all of this gracefully.

Unknown Component Handling

When the registry encounters a type it doesn't recognize, it should never crash. There are three strategies:

// Kotlin — Fallback strategies
enum class FallbackStrategy {
    SKIP,       // Silently skip the unknown component
    PLACEHOLDER, // Render a placeholder box
    NEAREST     // Try to find a similar registered component
}

class ResilientRegistry(
    private val strategy: FallbackStrategy = FallbackStrategy.SKIP
) {
    fun resolve(type: String): SDUIComponent {
        components[type]?.let { return it }
        
        // Log unknown type for monitoring
        analytics.track("sdui_unknown_component", mapOf(
            "type" to type,
            "appVersion" to BuildConfig.VERSION_NAME
        ))
        
        return when (strategy) {
            FallbackStrategy.SKIP -> EmptyComponent()
            FallbackStrategy.PLACEHOLDER -> PlaceholderComponent(type)
            FallbackStrategy.NEAREST -> findNearest(type) ?: EmptyComponent()
        }
    }
}

Network Failure Resilience

// Swift — Graceful degradation on network failure
class SDUIScreenLoader {
    private let cache: SDUICache
    private let api: SDUIApiClient
    private let bundledLayouts: [String: Layout]
    
    func loadScreen(_ route: String) async -> Layout {
        // Strategy 1: Try network
        if let fresh = try? await api.fetchLayout(route) {
            cache.store(route, layout: fresh)
            return fresh
        }
        
        // Strategy 2: Fall back to cache
        if let cached = cache.get(route) {
            return cached
        }
        
        // Strategy 3: Fall back to bundled layout
        if let bundled = bundledLayouts[route] {
            return bundled
        }
        
        // Strategy 4: Show error screen (never blank)
        return Layout.errorScreen(
            title: "Something went wrong",
            retryAction: Action.navigate(route)
        )
    }
}

The Fallback Cascade

Error Recovery Cascade Request Layout │ ▼ ┌─────────┐ Success ┌─────────────┐ │ Network │──────────────▶│ Render │ │ Fetch │ │ Fresh Layout│ └────┬────┘ └─────────────┘ │ Fail ▼ ┌─────────┐ Hit ┌─────────────┐ │ Check │──────────────▶│ Render │ │ Cache │ │ Stale Layout│ └────┬────┘ └─────────────┘ │ Miss ▼ ┌─────────┐ Found ┌─────────────┐ │ Bundled │──────────────▶│ Render │ │ Default │ │ Default │ └────┬────┘ └─────────────┘ │ None ▼ ┌─────────────┐ │ Error Screen│ │ + Retry │ └─────────────┘

✅ Golden Rule: Never Show a Blank Screen

Even in the worst case — no network, empty cache, no bundled layout — show something. An error screen with a retry button is infinitely better than a white screen or a crash. Your users will forgive stale data. They won't forgive a frozen app.

Pattern 8: Experimentation Integration

SDUI and experimentation are natural partners. Since the server controls the UI, A/B testing a layout change is as simple as returning a different JSON response. No app release required. No feature flags. No code changes on the client.

Automatic Exposure Tracking

When a user sees an experimental layout, you need to track that exposure automatically — not rely on engineers remembering to add analytics calls.

// Kotlin — Automatic experiment exposure tracking
class ExperimentAwareLayoutService(
    private val layoutService: LayoutService,
    private val experimentService: ExperimentService,
    private val analytics: Analytics
) {
    suspend fun getLayout(
        route: String,
        userId: String,
        context: RequestContext
    ): LayoutResponse {
        // Check if this route has active experiments
        val experiments = experimentService.getActiveExperiments(route)
        
        if (experiments.isEmpty()) {
            return layoutService.getLayout(route)
        }
        
        // Assign user to variant
        val assignments = experiments.map { exp ->
            val variant = experimentService.assign(userId, exp)
            ExperimentAssignment(exp.id, variant.id)
        }
        
        // Fetch the variant-specific layout
        val layout = layoutService.getLayout(route, assignments)
        
        // Return layout with experiment metadata
        return LayoutResponse(
            layout = layout,
            experiments = assignments
        )
    }
}

// Client-side: track exposure when layout is rendered
@Composable
fun ExperimentTrackedScreen(response: LayoutResponse) {
    LaunchedEffect(response.experiments) {
        response.experiments.forEach { assignment ->
            analytics.track("experiment_exposure", mapOf(
                "experimentId" to assignment.experimentId,
                "variantId" to assignment.variantId
            ))
        }
    }
    
    SDUIScreen(response.layout)
}

A/B Testing Layouts

Here's what A/B testing looks like when the server controls the UI. The same endpoint returns different layouts based on experiment assignment:

// Server-side layout resolution (pseudo-code)
fun resolveLayout(route: String, userId: String): Layout {
    val experiment = experiments.get("homepage_redesign_2026")
    
    return when (experiment.assignVariant(userId)) {
        "control" -> {
            // Original layout: hero → categories → products
            Layout(
                children = listOf(heroSection, categoryGrid, productList)
            )
        }
        "variant_a" -> {
            // Variant A: personalized feed → hero → categories
            Layout(
                children = listOf(personalizedFeed, heroSection, categoryGrid)
            )
        }
        "variant_b" -> {
            // Variant B: search-first → trending → categories
            Layout(
                children = listOf(searchBar, trendingSection, categoryGrid)
            )
        }
    }
}

Experiment Metadata in JSON

// Layout response with experiment metadata
{
    "route": "/home",
    "experiments": [
        {
            "id": "homepage_redesign_2026",
            "variant": "variant_a",
            "trackExposure": true
        }
    ],
    "layout": {
        "type": "screen",
        "children": [...]
    }
}

💡 SDUI Superpower: Zero-Deploy A/B Tests

With traditional native apps, running an A/B test on a UI layout requires: code the variant, ship a new build, wait for app review, wait for adoption. With SDUI, you change a JSON response and the experiment is live in seconds. No build. No review. No adoption lag.

How Pyramid Implements These Patterns

Pyramid was designed from the ground up with these 8 patterns as first-class concerns — not afterthoughts.

Component Registry

Pyramid ships with a hybrid registry: 40+ built-in primitives (text, image, button, form fields, lists, grids) plus a BYOC API for custom components. Registration is a single function call, and the fallback behavior is configurable per-screen.

Schema-First

Every Pyramid component is defined in a versioned JSON Schema. The Pyramid CLI generates Kotlin data classes and Swift Codable structs from these schemas, eliminating an entire class of serialization bugs. Schema validation runs on our cloud before layouts reach your app.

Action System

Pyramid's built-in action system supports navigation, HTTP calls, state mutations, analytics events, and action composition (sequences and conditionals). An action allowlist is enforced client-side to prevent unauthorized URL execution.

Data Binding

Pyramid supports {{variable}} expressions with dot-notation path resolution, comparisons, and boolean logic. Variables can be sourced from API responses, local storage, or the action system. State changes trigger reactive updates — the binding layer is integrated directly with Jetpack Compose's state and SwiftUI's @Observable.

Versioning

Pyramid uses header-based negotiation. The SDK sends its schema version and supported component list. The Pyramid cloud returns a compatible layout, automatically downgrading components that the client can't render to their closest supported equivalent.

Error Handling

Pyramid implements the full fallback cascade: network → cache → bundled → error screen. The SDK bundles a set of default layouts for critical screens, so even first-launch-offline works. Unknown components are silently skipped in production and visually flagged in debug builds.

Experimentation

Experiment assignment happens at the layout service level. Pyramid tracks exposure automatically when a variant layout is rendered. Integrate with your existing experimentation platform (LaunchDarkly, Statsig, Eppo) or use Pyramid's built-in experiment runner for layout-level A/B tests.

Build on Solid Architecture

Pyramid implements all 8 patterns out of the box — component registry, schema-first design, action system, data binding, BYOC, versioning, fallbacks, and experimentation. Stop reinventing the wheel.

Join the Waitlist →

Pattern Summary

Pattern What It Solves Complexity
Component Registry Mapping JSON types to native views Low
Schema-First Design Server/client contract drift Medium
Action System User interactions on dynamic screens Medium
Data Binding Dynamic content in static layouts Medium-High
BYOC vs Provided Design system integration Low (decision)
Versioning Multi-version app compatibility High
Fallback & Errors Production resilience Medium
Experimentation A/B testing without deploys Medium

What to Build First

If you're building an SDUI system from scratch, here's the order we recommend:

  1. Component Registry + Fallback — This is your foundation. Nothing works without it.
  2. Schema-First Design — Define your contracts early. It's 10x harder to retrofit schemas later.
  3. Action System — Without actions, your screens are read-only.
  4. Data Binding — This is what makes screens feel dynamic and personalized.
  5. Versioning — Critical once you have multiple app versions in the wild.
  6. Experimentation — The payoff that makes the whole investment worthwhile.

Or skip the build phase entirely and start with Pyramid, which implements all eight patterns on day one.

Skip the Build Phase

These 8 patterns represent months of architecture and engineering. Pyramid gives you all of them on day one, so you can focus on building product instead of infrastructure.

Get Early Access →

Further Reading

Related Articles