Building a Server-Driven UI System: Backend-Controlled Layouts, Dynamic Components, and Over-the-Air Updates in React
Server-driven UI flips the rendering model so the backend decides what components render and how they are arranged. This article covers the component registry pattern, JSON schema design, dynamic resolution in React, versioning, and when the complexity is actually worth it.
Most React applications treat the component tree as a client-side concern. The backend serves data; the frontend decides what to render. Server-driven UI (SDUI) inverts that relationship: the backend returns a description of the interface, and the client renders whatever the server prescribes. The client ships no routing logic, no feature flags per screen, and no release cycle just to change a button label.
This pattern has a real production history. Airbnb’s mobile app adopted it years ago to avoid App Store review delays. Several large e-commerce platforms use it to run experiments and roll out promotions without shipping new builds. On the web, it is less common but equally applicable when your use case actually calls for it.
This article walks through the concrete mechanics: the schema, the component registry, dynamic resolution in React with TypeScript, backward compatibility, and the honest tradeoffs.
The Core Idea
In a traditional React app, a page component hard-codes which child components it renders:
function ProductPage({ productId }: { productId: string }) {
const product = useProduct(productId);
return (
<div>
<HeroImage src={product.imageUrl} />
<PriceBlock price={product.price} originalPrice={product.originalPrice} />
<ReviewsSummary productId={productId} />
<AddToCartButton productId={productId} />
</div>
);
}
In a server-driven model, the backend returns a layout description and the client renders whatever the description says:
{
"type": "Screen",
"children": [
{ "type": "HeroImage", "props": { "src": "https://cdn.example.com/img.jpg" } },
{ "type": "PriceBlock", "props": { "price": 4999, "originalPrice": 7999 } },
{ "type": "ReviewsSummary", "props": { "productId": "abc123" } },
{ "type": "AddToCartButton", "props": { "productId": "abc123" } }
]
}
The client never hard-codes ProductPage. It just walks the tree and resolves each type to a registered component.
Defining the Schema
The schema is the contract between backend and frontend. Keep it simple and explicit. A recursive node type covers most cases:
// The base node that every component description extends
interface UINode {
type: string;
props?: Record<string, unknown>;
children?: UINode[];
// Optional stable key for React reconciliation
key?: string;
}
// Top-level screen response
interface ScreenResponse {
version: number;
screen: UINode;
}
The version field on ScreenResponse is not optional. Without it you have no path to backward compatibility when the schema changes.
For props, avoid deeply nested structures in the schema. If a component needs complex data, it should fetch it independently using the IDs passed through props. A layout node should describe structure and identity, not carry a full data payload.
// Avoid this pattern: embedding full data in the layout
{
"type": "ProductCard",
"props": {
"product": {
"id": "abc123",
"title": "Widget Pro",
"price": 4999,
"images": ["..."],
"attributes": { "...": "..." }
}
}
}
// Prefer this: pass the reference, let the component own its data
{
"type": "ProductCard",
"props": {
"productId": "abc123"
}
}
The second approach keeps the schema stable even when the product data model changes. It also lets you cache component renders separately from the layout response.
The Component Registry
The registry is a map from string type names to React components. It is the only place the client knows about component types. Everything else is data.
import type { ComponentType } from "react";
import type { UINode } from "./schema";
// All registered components accept at minimum the node's props
// plus optional children that the renderer passes down
export type SDUIComponentProps = {
children?: React.ReactNode;
[key: string]: unknown;
};
type Registry = Map<string, ComponentType<SDUIComponentProps>>;
const registry: Registry = new Map();
export function registerComponent(
type: string,
component: ComponentType<SDUIComponentProps>
): void {
if (registry.has(type)) {
console.warn(`SDUI: overwriting registered component "${type}"`);
}
registry.set(type, component);
}
export function resolveComponent(
type: string
): ComponentType<SDUIComponentProps> | null {
return registry.get(type) ?? null;
}
Register your components at app startup, before any rendering occurs:
import { registerComponent } from "./registry";
import { HeroImage } from "./components/HeroImage";
import { PriceBlock } from "./components/PriceBlock";
import { ReviewsSummary } from "./components/ReviewsSummary";
import { AddToCartButton } from "./components/AddToCartButton";
import { UnknownComponent } from "./components/UnknownComponent";
registerComponent("HeroImage", HeroImage);
registerComponent("PriceBlock", PriceBlock);
registerComponent("ReviewsSummary", ReviewsSummary);
registerComponent("AddToCartButton", AddToCartButton);
// Fallback for unknown types, discussed below
registerComponent("__unknown__", UnknownComponent);
The Renderer
The renderer walks the UINode tree recursively. It resolves each node’s type against the registry and renders it with the node’s props and any resolved children.
import React from "react";
import { resolveComponent } from "./registry";
import type { UINode } from "./schema";
interface SDUIRendererProps {
node: UINode;
}
export function SDUIRenderer({ node }: SDUIRendererProps): React.ReactElement | null {
const Component = resolveComponent(node.type);
if (!Component) {
// Resolve to fallback rather than crashing
const Fallback = resolveComponent("__unknown__");
if (!Fallback) return null;
return <Fallback nodeType={node.type} />;
}
const children =
node.children && node.children.length > 0
? node.children.map((child, index) => (
<SDUIRenderer key={child.key ?? index} node={child} />
))
: undefined;
const props = {
...(node.props ?? {}),
children,
} as React.ComponentProps<typeof Component>;
return <Component key={node.key} {...props} />;
}
The UnknownComponent fallback is important for production. When the server sends a type the current client does not recognize (because it is a newer client feature or a typo in the backend), the renderer degrades gracefully instead of throwing.
interface UnknownComponentProps {
nodeType?: string;
}
export function UnknownComponent({ nodeType }: UnknownComponentProps) {
if (process.env.NODE_ENV === "development") {
return (
<div style={{ border: "2px dashed red", padding: 8 }}>
Unknown SDUI component: <code>{nodeType}</code>
</div>
);
}
// In production, render nothing and let the rest of the screen work
return null;
}
Versioning and Backward Compatibility
Schema versioning is the hardest part of this pattern to get right. There are two failure modes you need to plan for:
- Old client, new server schema: The backend sends a type or prop the client does not know about.
- New client, old server schema: The frontend expects a prop that an older backend response does not include.
For (1), the __unknown__ fallback handles unknown types. For unknown props, TypeScript’s Record<string, unknown> means components receive extra props they can safely ignore.
For (2), every component must treat all props as optional with safe defaults:
interface PriceBlockProps {
price: number;
originalPrice?: number; // Was added in v2 of the schema
currencyCode?: string; // Was added in v3
}
export function PriceBlock({
price,
originalPrice,
currencyCode = "USD",
}: PriceBlockProps) {
return (
<div>
<span>{formatPrice(price, currencyCode)}</span>
{originalPrice !== undefined && (
<span style={{ textDecoration: "line-through" }}>
{formatPrice(originalPrice, currencyCode)}
</span>
)}
</div>
);
}
For the ScreenResponse.version field, use it to gate which rendering path you follow:
function renderScreen(response: ScreenResponse): React.ReactElement | null {
if (response.version > SUPPORTED_SCHEMA_VERSION) {
// Log the mismatch and render a minimal fallback
console.error(
`SDUI schema version ${response.version} exceeds supported version ${SUPPORTED_SCHEMA_VERSION}`
);
return <VersionMismatchFallback />;
}
return <SDUIRenderer node={response.screen} />;
}
On the backend, maintain at minimum two supported versions at a time. When you increment the schema version, keep the old version active for the duration of your oldest-supported client. On the web this is shorter (force-refresh is possible); on mobile it can be months.
Over-the-Air Updates
OTA updates are the main reason teams adopt SDUI on mobile. On the web, you already control deployment. But SDUI still lets you change what renders for a given user without a code deploy.
The mechanism is straightforward: the server response changes, the client re-renders. No client code was modified.
A few practical patterns:
Polling with ETags
async function fetchScreen(screenId: string): Promise<ScreenResponse> {
const cached = screenCache.get(screenId);
const headers: HeadersInit = {};
if (cached?.etag) {
headers["If-None-Match"] = cached.etag;
}
const response = await fetch(`/api/screens/${screenId}`, { headers });
if (response.status === 304 && cached) {
return cached.data;
}
const data: ScreenResponse = await response.json();
screenCache.set(screenId, {
data,
etag: response.headers.get("ETag") ?? undefined,
});
return data;
}
React Query integration
import { useQuery } from "@tanstack/react-query";
function useDynamicScreen(screenId: string) {
return useQuery({
queryKey: ["screen", screenId],
queryFn: () => fetchScreen(screenId),
staleTime: 60_000, // Consider fresh for 1 minute
refetchInterval: 300_000, // Background refresh every 5 minutes
});
}
export function DynamicScreen({ screenId }: { screenId: string }) {
const { data, isLoading, error } = useDynamicScreen(screenId);
if (isLoading) return <ScreenSkeleton />;
if (error || !data) return <ScreenError />;
return renderScreen(data);
}
This gives you: server-pushed layout changes without redeployment, A/B experiments controlled entirely from the backend, and rollback by changing what the server returns for a given segment.
Action Handling
Layouts often need interactivity: button clicks, form submissions, navigation. The schema needs a way to express these without hard-coding handlers in the client.
A simple action descriptor:
interface UIAction {
type: "navigate" | "submit" | "call" | "dismiss";
payload?: Record<string, unknown>;
}
Components receive actions as props:
{
"type": "AddToCartButton",
"props": {
"productId": "abc123",
"onPress": {
"type": "call",
"payload": { "endpoint": "/api/cart/add", "productId": "abc123" }
}
}
}
The client registers an action handler that interprets the descriptor:
function useActionHandler() {
const router = useRouter();
return useCallback((action: UIAction) => {
switch (action.type) {
case "navigate":
router.push(action.payload?.path as string);
break;
case "call":
fetch(action.payload?.endpoint as string, {
method: "POST",
body: JSON.stringify(action.payload),
});
break;
case "dismiss":
// Handled by modal context or navigation stack
break;
}
}, [router]);
}
Keep action types small and generic. If you find yourself adding "addToCart" as an action type, you have moved business logic back into the schema. The schema should express what happened; the client handler decides how to execute it.
Tradeoffs
| Concern | Server-Driven UI | Traditional CSR |
|---|---|---|
| Deploy to update UI | Server-only | Client + server |
| Backend complexity | High: schema design, versioning | Low |
| Frontend complexity | Medium: renderer, registry, fallbacks | Low per feature |
| Debuggability | Harder: requires inspecting payloads | Standard React DevTools |
| A/B experimentation | Server controls, no client changes | Requires flag system or redeploy |
| Offline support | Difficult without persistent cache | Standard patterns apply |
| TypeScript safety | Partial: runtime schema, not compile-time | Full static analysis |
| Component reuse | High: components are pure, data-agnostic | Contextual coupling common |
| Onboarding new engineers | Steep: unusual mental model | Standard React patterns |
The TypeScript safety row deserves more attention. Your component implementations can be fully typed, but the JSON coming from the server is not. The type boundary is at Record<string, unknown>, and you absorb runtime errors through defensive prop handling. Zod validation on the parsed response helps, but you are still doing runtime validation where you would normally have compile-time guarantees.
import { z } from "zod";
const UINodeSchema: z.ZodType<UINode> = z.lazy(() =>
z.object({
type: z.string(),
key: z.string().optional(),
props: z.record(z.unknown()).optional(),
children: z.array(UINodeSchema).optional(),
})
);
const ScreenResponseSchema = z.object({
version: z.number(),
screen: UINodeSchema,
});
function parseScreenResponse(raw: unknown): ScreenResponse {
return ScreenResponseSchema.parse(raw);
}
The recursive z.lazy call works but has a performance cost on large trees. Parse once at the API boundary, not on every render.
When to Use It
SDUI earns its complexity when these conditions are true:
- You need UI changes faster than your release cycle allows. On mobile this is the primary driver. On the web, you can deploy in minutes, so the bar is higher.
- The same component library must serve multiple clients. If a React web app and a React Native app share the same backend-controlled layout, SDUI avoids duplicating screen logic across codebases.
- A/B testing is a core product workflow and you need server-side control. With SDUI, experiments require no client changes. Segment a user on the server and return a different layout node.
- The layout itself changes frequently. If you are building a content platform, a promotional surface, or a configurable dashboard, SDUI pays for itself in avoided deploys.
It is not worth the complexity when:
- Your UI is stable and changes infrequently.
- Your team is small and deploy cycles are already fast.
- You need deep TypeScript safety end-to-end and cannot absorb runtime schema errors.
- The interactivity is complex and action descriptors would become a second programming language.
Production Considerations
A few patterns that come up once you run this in production:
Skeleton loading: Because the client does not know what it will render until the server responds, generic skeletons are your only option unless you cache the previous layout and show it immediately while fetching the new one.
Schema documentation: The schema is a public API between teams. Treat it that way. Use JSON Schema or a shared TypeScript type package to keep backend and frontend in sync. Undocumented schema changes are indistinguishable from bugs.
Monitoring unknown components: Log every __unknown__ render to your error tracker. A spike in unknown component renders means a backend deploy sent a new type the client does not know about yet, or a typo slipped through review.
Payload size: Layout descriptions can get large for complex screens. Apply gzip compression, consider pagination for long lists, and set a hard limit on nesting depth. A runaway recursive structure from the server should not crash the renderer.
SDUI is a real architectural tool with a real cost. On mobile, the cost/benefit is often obvious. On web, measure the problem first. If your deploy cycle is fast and your UI is stable, a clean component tree with good feature flags will serve you better.
The renderer is not the hard part. Versioning is.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.