Server-Driven UI (SDUI): Designing a React Client Architecture Capable of Rendering Dynamic, Type-Safe Layouts Driven Entirely by API Payloads
Modern product teams ship UI changes weekly, sometimes daily. But every native or web release still has to pass through a build pipeline, an app store review, or a deployment window. Server-Driven UI (SDUI) breaks that bottleneck by treating the screen itself as data — a payload the backend can reshape at will, and the client simply renders. This article walks through how to design a React architecture that consumes SDUI payloads safely, predictably, and without sacrificing TypeScript's compile-time guarantees.
What Is Server-Driven UI, Really?
In a traditional React app, the layout, component tree, and business logic all live in the client bundle. Changing a hero banner's copy or adding a new promotional card means writing code, testing it, and shipping a new build.
Server-Driven UI inverts this. Instead of the client deciding what to render, the server sends a structured payload — usually JSON — that describes a tree of components, their props, and their layout relationships. The client's only job is to interpret that payload and map it onto real React components.
The React client doesn't know in advance that a "banner" or "productCarousel" will appear on this exact screen — it only knows how to render a banner or productCarouselif one shows up. This is the fundamental contract shift: from "the client owns the layout" to "the client owns the vocabulary, and the server owns the sentence."
Why Teams Adopt SDUI
Release velocity — layout and content changes ship instantly through a config or CMS update, no app store review or client deploy required.
Experimentation at scale — A/B tests and personalization can rearrange, swap, or hide entire sections per user segment without client-side feature flags scattered through the codebase.
Platform consistency — a single payload schema can drive iOS, Android, and web clients simultaneously, keeping product experiences in sync.
Reduced client logic — business rules ("show this banner only to new users") move server-side, where they're easier to audit and change.
The trade-off is real, though: you're giving up some of React's natural type safety and pushing correctness checks to runtime. The rest of this article is about clawing that safety back.
Client-Driven vs. Server-Driven: Where to Draw the Line
Very few production apps are 100% server-driven. A pragmatic architecture usually looks like a spectrum:
Layer
Ownership
Example
Navigation shell, auth, core routing
Client
Tab bar, login flow
Screen content and layout
Server (SDUI)
Home feed, promotional screens, dashboards
Interaction primitives
Client (registry)
Button, Card, Carousel components
Business logic on interaction
Server (actions/deep links)
"Add to cart", "Navigate to PDP"
The client owns a registry of components it knows how to render. The server owns the arrangement of those components. This separation is what keeps SDUI from turning into "ship arbitrary code from the backend," which is both a security and a maintainability nightmare.
Core Architectural Principles
Before writing any code, four principles should anchor the design:
Schema-first contracts. The payload shape is a versioned contract, not an implicit agreement. Define it once, generate types from it, and validate against it on both ends.
Closed component registry. The client never executes arbitrary logic from the payload — it only maps a known type string to a known React component. Unknown types degrade gracefully.
Runtime validation, not just compile-time types. TypeScript types disappear at runtime. Since the payload comes from a network boundary, it must be validated with something like Zod before it touches your render tree.
Backward and forward compatibility. Old clients will receive new payload shapes eventually. The renderer must fail soft, not crash.
Designing the Type-Safe Payload Contract
The heart of a resilient SDUI system is the schema. There are two common approaches:
Option A: TypeScript-first with Zod
Define your component schemas with Zod, and derive both the runtime validator and the static TypeScript type from a single source of truth.
The discriminated union pattern is essential here. Because every node schema shares a literal type field, TypeScript can narrow the type automatically inside your renderer's switch statement, and Zod can pick the right sub-schema during validation, both without manual casting.
Option B: Schema-first with JSON Schema / OpenAPI
If the backend is polyglot (Go, Java, Python) and multiple client platforms consume the same payload, define the contract in JSON Schema or as part of an OpenAPI specification, and generate TypeScript types for the React client using a codegen tool. This keeps the source of truth backend-agnostic and avoids type drift between platforms.
Either approach works — the important part is that one schema, versioned in source control, is the single source of truth, and every client and server implementation is generated or validated against it, not hand-typed independently.
Building the React Renderer
With a validated payload in hand, the renderer's job is to recursively walk the tree and map each node to a component instance.
Step 1: The Component Registry
A registry is a plain object (or Map) associating a type string with a React component. This is the single point of truth for "what can this client render."
import { Banner } from "./components/Banner";
import { ProductCarousel } from "./components/ProductCarousel";
import type { ComponentType } from "react";
const componentRegistry: Record<string, ComponentType<any>> = {
banner: Banner,
productCarousel: ProductCarousel,
};
Step 2: The Recursive Renderer
import { ScreenSchema, type ScreenPayload } from "./schema";
function SduiNode({ node }: { node: ScreenPayload["children"][number] }) {
const Component = componentRegistry[node.type];
if (!Component) {
console.warn(`Unknown SDUI component type: ${node.type}`);
return null;
}
return <Component {...node.props} />;
}
export function SduiScreen({ raw }: { raw: unknown }) {
const result = ScreenSchema.safeParse(raw);
if (!result.success) {
console.error("Invalid SDUI payload", result.error);
return <FallbackScreen />;
}
return (
<>
{result.data.children.map((node, i) => (
<SduiNode key={i} node={node} />
))}
</>
);
}
Two design decisions matter here:
safeParse, not parse. A malformed payload should never throw and crash the render tree; it should degrade to a fallback UI.
Unknown component types render null, not an error. This is what makes forward compatibility possible — an older client receiving a payload with a component type it doesn't yet recognize (because a newer client shipped it first) simply skips that node instead of breaking the whole screen.
Step 3: Recursive Layout Containers
Real screens need nesting — rows, columns, conditional sections. Model layout containers as just another node type whose children are themselves nodes:
z.lazy is required here because the schema is recursive — a stack can contain other stacks. The renderer handles this the same way: if node.children exists, recurse into SduiNode for each child after rendering the container.
Handling Interactivity: Actions, Not Code
The biggest security and architecture trap in SDUI is trying to send behavior from the server — literal JavaScript, or free-form event handlers. Don't. Instead, model interactions as declarative action objects that the client interprets through its own, closed set of handlers.
This keeps the attack surface small: the server can only trigger behaviors the client has explicitly implemented and whitelisted, never arbitrary code execution.
Performance Considerations
SDUI adds a network round-trip and a validation pass before the first paint, so performance work matters more than in a fully static client.
Code-split the registry. Use React.lazy so rarely used component types (e.g., a seasonal promo widget) aren't in the initial bundle.
Memoize aggressively. Since payloads are often re-fetched on navigation or polling, wrap leaf components in React.memo and ensure prop identity is stable to avoid unnecessary re-renders — this matters even more in SDUI trees because the payload object is freshly parsed on every fetch, so naive equality checks will always "fail" unless you memoize at the right boundary.
Stream where possible. For large screens, consider streaming the payload with React Suspense boundaries per section rather than blocking the entire screen on one large JSON response.
Cache validated payloads. Since Zod parsing has a cost, cache the parsed (not raw) result keyed by payload hash or ETag, so identical payloads aren't re-validated on every render.
Versioning and Backward Compatibility
Because the client and server release independently, the payload schema needs an explicit versioning strategy:
Additive changes only within a minor version. New optional fields and new component types are safe; never repurpose an existing field.
A version field on every payload, checked by the client to decide whether to render, degrade, or prompt an app update.
Unknown-node tolerance (as shown above) so older clients don't hard-fail on payloads containing components introduced after they shipped.
Schema snapshot testing — keep fixture payloads from every shipped schema version in your test suite so a future schema change can't silently break rendering for a payload an old backend might still send.
Testing an SDUI Renderer
Testing shifts from "does this component render given these props" to "does the renderer correctly interpret this payload." A solid test suite includes:
Schema validation tests — valid and invalid fixture payloads against your Zod schemas.
Snapshot tests per component type — rendering each registry entry with representative props.
Fallback behavior tests — malformed payloads, unknown types, and missing required fields should all degrade gracefully, never throw.
Contract tests against the backend — verifying the server's actual payload output continues to satisfy the schema, ideally run in CI on both sides of the contract.
Common Pitfalls
Treating the payload as trusted input. It crosses a network boundary; validate every time, even from your own backend.
Letting the registry sprawl. Every new component type is a permanent maintenance commitment across every client platform — resist adding one-off types for single-use screens.
Sending logic instead of data. Conditional rendering rules, formatting logic, and computed values belong server-side, resolved into flat, renderable data before the payload reaches the client.
Skipping the fallback UI. A production SDUI screen with no fallback for validation failure is one bad deploy away from a blank screen for real users.
Server-Driven UI trades some of React's compile-time certainty for runtime flexibility — but that trade doesn't have to mean fragility. A closed component registry, a schema-first contract validated at the network boundary with Zod or JSON Schema, declarative actions instead of server-sent logic, and a fail-soft renderer together produce a client that's both dynamic and dependable. The server gets to reshape the product in near real time; the client gets to keep its guarantees about what can actually appear on screen.
3Demystifying the Rust Borrow Checker: Fix Lifetime Errors Fast