Web Engineering ·

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.

Building a Server-Driven UI System: Backend-Controlled Layouts, Dynamic Components, and Over-the-Air Updates in React

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:

  1. Old client, new server schema: The backend sends a type or prop the client does not know about.
  2. 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

ConcernServer-Driven UITraditional CSR
Deploy to update UIServer-onlyClient + server
Backend complexityHigh: schema design, versioningLow
Frontend complexityMedium: renderer, registry, fallbacksLow per feature
DebuggabilityHarder: requires inspecting payloadsStandard React DevTools
A/B experimentationServer controls, no client changesRequires flag system or redeploy
Offline supportDifficult without persistent cacheStandard patterns apply
TypeScript safetyPartial: runtime schema, not compile-timeFull static analysis
Component reuseHigh: components are pure, data-agnosticContextual coupling common
Onboarding new engineersSteep: unusual mental modelStandard 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
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
Web Engineering ·

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
Web Engineering ·

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
Web Engineering ·

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.