cf.completefrontend
My progress
FUNDAMENTALS · MODULE 17

Config-driven UI

Describing screens as JSON that names known components, rendered through an allowlisted registry, so layouts change without an app release: safe only if configs are validated, never executed, and older clients get fallbacks.

The problem: every change waits for a release

Picture a shopping app with a home screen: a hero banner, a row of deals, a grid of categories, a newsletter box. Marketing wants a new banner for a sale that starts on Friday. On the web that means a code change, a review, a deploy. On iOS and Android it means a new build, an app store review that can take a day or more, and then waiting for people to update.

Weeks later, a good share of phones may still run the old build. The sale banner is over by the time most users see the new layout. The team that owns the home screen spends more time shipping small layout changes than building features.

Config-driven UI is the answer many teams reach for. Instead of hard-coding the layout in the app, the app asks for a description of the screen: a piece of data (usually JSON) that says which components to show, in which order, with which props. The app turns that description into real UI. Changing the screen now means changing data, not code.

What it looks like

Here is the home screen above as a config. Each item names a component type and passes it props. Some items hold children, so the config is a tree, the same shape as the React tree it will become.

json
{
  "version": 3,
  "id": "home",
  "type": "Page",
  "children": [
    { "id": "h1", "type": "Hero", "props": { "title": "Friday sale", "image": "/img/sale.webp",
        "cta": { "label": "Shop now", "action": { "type": "navigate", "to": "/sale" } } } },
    { "id": "r1", "type": "ProductRow", "props": { "source": "deals:today", "limit": 8 } },
    { "id": "g1", "type": "CategoryGrid", "props": { "columns": 4 } },
    { "id": "n1", "type": "Newsletter", "props": { "headline": "Get the deals first" } }
  ]
}
A home screen described as data. The app ships the four components; the config decides which ones appear, in what order, with what text.
Two columns. Hard-coded: a layout change goes through a code change with review, an app store review taking hours to days, and users updating over weeks, so many users see the change late. Config-driven: edit the config in the CMS, validate and publish in minutes, and every app version that knows the components shows it on its next load.hard-coded screenconfig-driven screencode changereview, mergeapp store reviewhours to daysusers updateweeks for mostedit the configin the CMSvalidate, publishminutesnext app loadevery version
The same banner change. With hard-coded screens it waits for the next release; with a config it is data that every installed app can already draw.

Notice what the config does not contain: no code, no HTML, no styles. It only names things the app already knows how to draw. That one rule, data that names known components and never carries code, is what keeps the approach safe. Most of this lesson is about holding that line while the system grows.

Where you meet it

  • CMS-driven pages: editors build landing pages in a headless CMS (Contentful, Sanity, Storyblok, Builder.io) by stacking blocks; the site renders the blocks.
  • Server-driven UI (SDUI): a backend sends the layout of a native or web screen per request, so the same app build can show different screens to different users, markets, or experiments.
  • Form builders: a JSON Schema or a custom form config describes fields, types, and rules; one generic form engine renders and validates them (think survey tools, admin panels, insurance quotes).
  • Dashboards and admin tools: users pick widgets and their settings; the saved layout is a config the app re-renders.
  • Email and notification templates: blocks of known types assembled by non-engineers.

Think first

If changing data is so much faster than shipping code, why not make every screen config-driven?

The mental model: data names, code draws

Keep one sentence in your head: the config names things, the code draws them. The config decides what appears and in which order. The components decide how each thing looks, behaves, and stays accessible. Nothing in the config can add a new kind of thing.

A good everyday comparison is a restaurant order slip. The waiter writes "2 x soup, 1 x pasta, no cheese". The slip can only name dishes on the menu and the options the menu allows.

If someone scribbles "lobster thermidor" on a slip in a pizza place, the kitchen doesn't improvise: it sends the slip back. The slip is the config, the menu is the registry, the kitchen is the renderer.

The five parts

Every config-driven system, from a two-person form builder to a server-driven mobile app, has the same five parts. Naming them makes the design discussion much easier, because each part has its own owner and its own failure modes.

  1. The config: plain data, usually JSON. A tree of nodes, each with a type, props, and maybe children. It is written by people (editors, product managers) or produced by a service.
  2. The schema: the rules the config must follow. Which types exist, which props each type takes, which values are allowed. It is code, owned by engineers, and it is checked in more than one place.
  3. The registry: a map from each type name to a real component, such as Hero to the Hero React component. It is also the allowlist: if a name is not in the map, nothing renders for it.
  4. The renderer: a small function that walks the config tree, looks each node up in the registry, and builds the React tree. It also decides what to do with nodes it doesn't recognise.
  5. The components: ordinary, typed, tested React components, usually from the design system. They don't know they're being driven by config, which keeps them reusable.
A stack of five layers from top to bottom: the config, JSON from a CMS or API, written by editors and services; then a dashed trust boundary; then the schema check with Zod or JSON Schema, the registry mapping type to component, the renderer that walks the tree, and the design system components, all owned by engineers.configJSON from CMS or APIeditors, servicesschema checkZod, JSON Schemaengineersregistrytype to componentengineersrendererwalks the treeengineerscomponentsdesign systemdesign system teamtrust boundary
Everything above the dashed line is untrusted input, even when a colleague wrote it. Everything below is reviewed code shipped through normal releases.

Read the stack from top to bottom and notice where trust changes. Everything above the schema check is untrusted input, even if a colleague wrote it in your own CMS: it can be stale, mistyped, or tampered with. Everything below it is your code, reviewed and shipped through the normal release process.

What config-driven UI is not

It is not sending HTML from the server. Server-rendered HTML is also "UI from the server", but the browser executes it directly, so anything in it runs. A config is interpreted by your renderer, and the renderer only knows a fixed list of moves.

It is not a way to ship new features without code either. The config can only recombine what already exists. A useful test: if a request needs a new component or a new prop, it is an app change; if it only rearranges or retexts existing ones, it is a config change. On a busy marketing surface, such as a home page or a landing page, most day-to-day requests fall in the second group, which is why the approach pays off there first.

Config-driven UI sits between hard-coded screens and raw HTML: flexible like HTML, but limited to moves the app already knows.
QuestionHard-coded UIConfig-driven UIServer-rendered HTML
Who decides the layoutCode in the appData from a CMS or APIServer templates
Change without a releaseNoYes, within known componentsYes, for web only
Can the payload run codeNot applicableNo, if you never evalYes, the browser runs it
Works for native appsYesYes, same configNo
Type safetyCompile timeRuntime schema checksTemplate engine only

Check your understanding · Exercise 1

A product manager asks to show a countdown timer on the home page for the Friday sale. The app has Hero, ProductRow, CategoryGrid, and Newsletter in its registry. Is this a config change? Pick one.

How it works: a renderer in 20 lines

The renderer is smaller than most people expect. It is a recursive function: take a node, find its component, render its children the same way, and hand everything to React. The interesting decisions are in the edges: what to do with an unknown type, how to key children, and how deep a tree may go.

Here is a version you can use as-is. It takes the registry as a Map, a function that creates elements (React's createElement in the app, a plain object builder in tests), and a fallback for unknown types.

ts
export type ConfigNode = {
  id: string;
  type: string;
  props?: Record<string, unknown>;
  children?: ConfigNode[];
};
type Make<T> = (component: unknown, props: Record<string, unknown>, children: T[]) => T;

export function makeRenderer<T>(
  registry: Map<string, unknown>,
  make: Make<T>,
  fallback: (node: ConfigNode) => T | null,
) {
  function render(node: ConfigNode, depth = 0): T | null {
    if (depth > 12) return null; // a runaway tree is a bug, not a layout
    const component = registry.get(node.type);
    if (!component) return fallback(node);
    const children = (node.children ?? [])
      .map((child) => render(child, depth + 1))
      .filter((child): child is T => child !== null);
    return make(component, { ...node.props, key: node.id }, children);
  }
  return render;
}
The whole renderer. Lookups go through a Map, so a type like constructor can never resolve to something inherited from Object.prototype.

Walking one config

Take the home config from the first chapter and add one node the app doesn't know: { "id": "c1", "type": "Countdown" }. The renderer visits the tree depth first.

Page is found, so it renders its five children in order: Hero, ProductRow, and CategoryGrid are found. Countdown is not, so the fallback runs and returns null (or a small placeholder), and then Newsletter is found. The result is a Page element with four children, and one report saying "unknown type Countdown on home".

On the left, the config tree: Page with children Hero, ProductRow, CategoryGrid, Countdown, and Newsletter. On the right, the React tree it becomes: PageLayout with HeroBlock, ProductRowBlock, CategoryGridBlock, and NewsletterBlock. Countdown maps to nothing and is reported.config (data)React tree (your code)PagePageLayoutHeroHeroBlockProductRowProductRowBlockCategoryGridCategoryGridBlockCountdownnothing, reportedNewsletterNewsletterBlockregistry.get(type) for every node
The config and the React tree have the same shape. Each node becomes the component the registry names; a node without an entry becomes nothing, not an error page.

Step through the same walk below. Watch each node being looked up in the registry, and notice that a missing type removes one block, not the page.

The renderer walking a home page configStep 1 of 7: Start at the root

A config tree on the left: Page with five children, Hero, ProductRow, CategoryGrid, Countdown, and Newsletter. A registry on the right lists Page, Hero, ProductRow, CategoryGrid, and Newsletter. Each step looks one node up in the registry; Countdown is not found, so it is skipped and reported while the rest render.config treePageHeroProductRowCategoryGridCountdownNewsletterregistryPageHeroProductRowCategoryGridNewsletter
The renderer gets the page config and looks up its root type, Page, in the registry. Found: it will render Page and then each child in order.

Start at the root: The renderer gets the page config and looks up its root type, Page, in the registry. Found: it will render Page and then each child in order.

Three details that matter

  • Keys come from the config. Give every node a stable id and use it as the React key. Index keys break when an editor reorders blocks: React reuses the wrong component state, so a carousel keeps the scroll position of the block that used to be there.
  • Lookups must not reach the prototype. With a plain object, registry["constructor"] returns the Object function, which React would then try to call as a component. Use a Map, Object.hasOwn, or an object created with Object.create(null).
  • Each block gets an error boundary. Wrap every rendered block in a small boundary that catches render errors, reports them with the node id and type, and shows nothing. A bug in one promotional tile should never blank the checkout button below it.

Testing it in Node

Because the renderer takes the element factory as an argument, you can test it with plain objects and Node's built-in test runner, no browser and no React needed. This is also how you would test a schema or a migration: pure functions, fast tests.

js
import { test } from "node:test";
import assert from "node:assert/strict";
import { makeRenderer } from "./render.ts";

const make = (component, props, children) => ({ name: component.name, props, children });
function Page() {}
function Hero() {}
const unknown = [];
const render = makeRenderer(new Map([["Page", Page], ["Hero", Hero]]), make, (node) => {
  unknown.push(node.type);
  return null;
});

test("renders known types and skips unknown ones", () => {
  const tree = render({ id: "p", type: "Page", children: [
    { id: "a", type: "Hero", props: { title: "Sale" } },
    { id: "b", type: "Countdown" },
  ] });
  assert.deepEqual(tree.children.map((child) => child.name), ["Hero"]);
  assert.equal(tree.children[0].props.title, "Sale");
  assert.deepEqual(unknown, ["Countdown"]);
});

test("names inherited from Object are not components", () => {
  assert.equal(render({ id: "x", type: "constructor" }), null);
});
Run with node --test: Node 22.18 and newer strip the TypeScript types by default (older releases need --experimental-strip-types). In the app, pass createElement as make.

Check your understanding · Exercise 2

An editor drags the second carousel above the first. Users report that the top carousel now opens on slide 4, where they had scrolled the old top carousel. What is the most likely cause? Pick one.

The registry: an allowlist, not a lookup table

It is tempting to see the registry as a convenience, a map so the renderer can find components by name. It is more important than that. The registry is the allowlist of everything a config can ever produce. Its size is the size of your attack surface, your bundle, and your support burden.

A good registry holds three kinds of entries, each mapping a name in the config to something in your code: components for things to draw, actions for things to do, and data sources for things to load. None of them ever accepts code from the config.

Three maps from names to code. Components: Hero to HeroBlock, ProductRow to ProductRowBlock, Cta to CtaBlock, Map to a lazy MapBlock. Actions: navigate to router push, addToCart to cart add. Data sources: deals today to the deals query, bestsellers to the top query. A lookup of constructor, which is not listed, returns undefined from a Map, and the fallback runs.componentsHeroHeroBlockProductRowProductRowBlockCtaCtaBlockMaplazy(MapBlock)actionsnavigaterouter.push(to)addToCartcart.add(sku)data sourcesdeals:todaydealsQuery(today)bestsellerstopQuery(24)constructorundefined: fallbacknot listed
The config may only use names on the left. Each name points at reviewed code on the right. A Map has no inherited keys, so constructor or __proto__ find nothing.

Register adapters, not raw components

Don't register your design-system Button directly. Register a thin adapter, ButtonBlock, that takes the config props, maps them to the real component's props, and ignores everything else. The config contract and the component API then evolve separately: you can rename a prop in Button without breaking a thousand saved configs.

Adapters also stop a quiet security hole. Many components forward unknown props to the DOM with ...rest. If the renderer spreads node.props straight into such a component, a config could set dangerouslySetInnerHTML, style, or an on handler string. The adapter only reads the props it knows, and the schema (next chapter) strips the rest before they ever arrive.

tsx
// The config contract for a CTA: small, flat, versioned with the schema.
type CtaBlockProps = { label: string; action: ActionRef; tone?: "primary" | "secondary" };

function CtaBlock({ label, action, tone = "primary" }: CtaBlockProps) {
  const run = useAction(action); // looks the action up in the action registry
  return (
    <Button variant={tone === "primary" ? "solid" : "outline"} onClick={run}>
      {label}
    </Button>
  );
}

export const blocks = new Map<string, ComponentType<any>>([
  ["Hero", HeroBlock],
  ["ProductRow", ProductRowBlock],
  ["Cta", CtaBlock],
]);
The adapter picks the props it understands and maps them to the design system. Text goes in as React children, so React escapes it.

Actions and data, by name

Sooner or later a config needs behaviour: "this button opens the sale page", "this tile adds to cart", "this row shows today's deals". The answer is never a function in the config. It is a named action with data: { "type": "navigate", "to": "/sale" } or { "type": "addToCart", "sku": "A-1042" }. The app has an action registry that maps navigate and addToCart to real, tested functions.

Data works the same way. "source": "deals:today" names a query the app knows how to run, with its own caching and error states. The config picks which query; it never contains a URL to call or a query to execute. That keeps the config portable (the same config works on web, iOS, and Android) and keeps the server in charge of what data exists.

Decision How should the registry load its components?

One static registry

Import every block at the top of the registry file.

The renderer and all blocks ship in one chunk.

Pros

  • Simplest; no loading states inside the page.
  • Fine when there are 10 to 20 small blocks.

Cons

  • Every page pays for every block, including the map, video player, and chart nobody on this page uses.
  • Grows silently as teams add blocks.

Lazy entries

Register lazy(() => import("./MapBlock")) for heavy blocks.

Wrap blocks in Suspense; prefetch the chunks a config names as soon as the config arrives.

Pros

  • A page downloads only the blocks its config uses.
  • Heavy blocks stop taxing light pages.

Cons

  • Extra request per new block type; a spinner if you don't prefetch.
  • Needs placeholders sized to avoid layout shift.

Registry per surface

Home, checkout, and the form builder each get their own small registry.

Shared blocks live in a common module both import.

Pros

  • Checkout can never render a marketing block, by construction.
  • Smaller allowlists, smaller bundles.

Cons

  • More registries to keep in sync with the schema.
  • Moving a block to a new surface is a code change.

What we pick: Start with one static registry while there are a handful of blocks. Once a block costs more than about 20 KB compressed or appears on few pages, make it lazy and prefetch it from the config. For sensitive surfaces like checkout or account settings, give them their own small registry: the smallest allowlist is the safest.

Check your understanding · Exercise 3

A config node is { "type": "Cta", "props": { "label": "Buy", "onClick": "fetch('/api/transfer', { method: 'POST' })" } }. The Cta adapter only reads label, action, and tone. What happens? Pick one.

Validating configs: at build, at publish, at runtime

A config comes from outside your code, so treat it like any other input. A human typed it in a CMS, a script generated it, or an old tool saved it two years ago. Without checks, the first sign of a mistake is a broken page in production, because TypeScript types vanish at runtime and can't see JSON that arrives over the network.

The fix is a schema: one description of every valid block, written once in code and checked in several places. In TypeScript, a schema library such as Zod or Valibot gives you both the runtime check and the static type from the same source, the same pattern as validating API responses at the boundary.

ts
import { z } from "zod";

const Action = z.discriminatedUnion("type", [
  z.object({ type: z.literal("navigate"), to: z.string().regex(/^\/(?!\/)[\w\-/]*$/) }),
  z.object({ type: z.literal("addToCart"), sku: z.string().max(40) }),
]);

const Hero = z.object({
  id: z.string(),
  type: z.literal("Hero"),
  props: z.object({
    title: z.string().min(1).max(80),
    image: z.string().startsWith("/img/"),
    cta: z.object({ label: z.string().min(1).max(30), action: Action }).optional(),
  }),
});
const ProductRow = z.object({
  id: z.string(),
  type: z.literal("ProductRow"),
  props: z.object({
    source: z.enum(["deals:today", "bestsellers", "recently-viewed"]),
    limit: z.number().int().min(1).max(24),
  }),
});

export const Block = z.discriminatedUnion("type", [Hero, ProductRow]);
export type Block = z.infer<typeof Block>;
Each block is an object with a literal type, so the union can pick the right rule set. Zod objects strip unknown keys by default; .strict() rejects them instead.

What a schema should check

  • Known types only. The union lists every block type. Anything else fails, and the renderer's fallback takes over.
  • Props by type. Required props present, correct types, enums instead of free strings where the set is known (source, tone, columns).
  • Limits. Maximum string lengths, list sizes, and tree depth. A 2 MB config with 5,000 nodes is a bug or an attack, and both should fail fast.
  • Safe values. Links start with / (but not //, which means another host) or https: on an allowlist, image paths point at your image service, colours come from design tokens by name ("tone": "accent"), never raw CSS.
  • Cross-field rules. "A Carousel has 2 to 12 slides", "a form has exactly one submit". Zod's refine handles these; keep them few and readable.

Check in four places

The same schema runs at four points, each catching mistakes earlier than the next. The cheapest bug is one an editor sees as a red field in the CMS before they hit publish.

Four checks in a row, left to right: in the editor, at publish, in CI, and at runtime. Passing one leads to the next, and passing all four leads to render. Failing in the editor shows a red field; at publish the change is not published; in CI the build fails; at runtime the block is dropped and reported.pass: go on to the next check, then rendereditorfailred fieldpublishfailnot publishedCIfailbuild failsruntimefaildrop blockcheapest on the left, last resort on the right
One schema, four gates. The further right a mistake gets, the more it costs, so the editor and publish checks matter most; the runtime check is the safety net.
  1. In the editor. Export the schema as JSON Schema (Zod 4 has z.toJSONSchema) and feed it to the CMS or editor, so fields have the right types, dropdowns, and limits as people type.
  2. At publish. The CMS calls a webhook or a validation function before a change goes live. A config that fails is not published; the editor sees which block and which prop.
  3. In CI. Every saved config and every fixture is validated against the schema in a test. When an engineer changes the schema, CI tells them which live configs would break.
  4. At runtime, in the client or a BFF (backend-for-frontend, a server layer built for one app). The app validates what it actually received, because the network, caches, and old content can still surprise you. This is the last line, so its job is to degrade gracefully.

When runtime validation fails

Validate the page envelope strictly (version, type, a list of children no longer than 40), then validate each block on its own with safeParse. A bad block is dropped and reported with its id; the rest of the page renders. If the envelope itself is broken, fall back to the last known good config cached on the device or a static default page bundled with the app.

Try it. Edit the layout below and watch three things change: the rendered page, the list of validation errors, and what an older app build (one that doesn't know the newest block) would show.

Load an example:

Validation report: no issues.

App 5.2 (knows VideoHero): 4 blocks rendered.

  1. Hero: "Friday sale" with image /img/sale.webp, button "Shop now" to /sale
  2. ProductRow: 8 products from deals:today
  3. CategoryGrid: 4 columns
  4. Newsletter: "Get the deals first"

App 5.0 (older build): 4 blocks rendered.

  1. Hero: "Friday sale" with image /img/sale.webp, button "Shop now" to /sale
  2. ProductRow: 8 products from deals:today
  3. CategoryGrid: 4 columns
  4. Newsletter: "Get the deals first"

Check your understanding · Exercise 4

An editor publishes a home page where one ProductRow has limit: 200. The schema allows 1 to 24. Publish-time validation was skipped because of an outage in the webhook. What should the app do? Pick one.

Never eval: keeping config as data

Every config-driven system meets the same request within a few months: "can this block show only for some users?" Someone adds a field like "visibleIf": "user.cartTotal > 50" and evaluates it with new Function or eval. It works on the first try. It also turns the config into a program, and everyone who can edit a config can now run JavaScript in every user's browser.

That is stored XSS (cross-site scripting: an attacker's script running on your origin) with the widest possible reach. The script can read whatever the page can read, call your APIs with the user's cookies, and change what users see. A phished CMS account or a bug in an import script becomes an incident for every visitor.

A config for a Promo block, one line per field. visibleIf holds an expression, which would need eval. body holds raw HTML with an onerror attribute, an XSS sink. href holds a javascript URL. style holds CSS with a background URL that can leak data. Each is marked as unsafe.{ "type": "Promo","visibleIf": "user.total > 50",an expression: needs eval"body": "<img onerror=...>",raw HTML: an XSS sink"href": "javascript:steal()",a script URL"style": "background:url(//x)" }CSS that can leak dataconfig is data: never run it
Each of these fields turns data back into code. A strict schema strips or rejects them, and the registry adapters never read them.

The five doors to close

Each door turns data back into code. The schema and the registry together keep them shut.
DoorWhat it looks like in a configWhat to do instead
Expressions"visibleIf": "user.cartTotal > 50" run with eval or new FunctionResolve rules on the server, or use a tiny allowlisted rule format the app interprets
Raw HTML"body": "<p>Hi</p>" rendered with dangerouslySetInnerHTMLStructured rich text (a JSON tree of paragraphs, marks, links) rendered by your components
Links"href": "javascript:..." or a lookalike domainSchema allows only / paths or an allowlist of https: hosts
Styles"style": "background: url(https://evil.example/?leak)"Named tokens: "tone": "accent", "spacing": "lg"
Component names"type": "constructor" or a path like "./admin/Panel"Map lookups in a fixed registry; never import() a name from the config

When you really need conditions

Most conditions are about who the user is: new or returning, which country, which experiment arm, which plan. The server already knows all of that. The cleanest design resolves the rules on the server and sends each user a config that only contains what they should see. The client never sees the rule, so it can't be tampered with, and old clients don't need to understand it.

When a rule must run on the device (it depends on local state, like the cart or the time of day offline), use a small declarative format with a fixed set of operators, interpreted by your own code. For example { "when": { "cartTotalAtLeast": 50 } } maps to one tested function. Libraries like JSON Logic offer a general version; a handful of named conditions is usually easier to reason about and to validate.

ts
const conditions = {
  cartTotalAtLeast: (min: number, ctx: Ctx) => ctx.cartTotal >= min,
  signedIn: (wanted: boolean, ctx: Ctx) => ctx.signedIn === wanted,
  locale: (list: string[], ctx: Ctx) => list.includes(ctx.locale),
};

function matches(when: Record<string, unknown>, ctx: Ctx): boolean {
  return Object.entries(when).every(([name, arg]) => {
    if (!Object.hasOwn(conditions, name)) return false; // unknown rule: hide the block
    return conditions[name as keyof typeof conditions](arg as never, ctx);
  });
}
A rule format with three operators. Unknown operators fail closed: the block stays hidden and the miss is reported.

Backstops

Defence in depth still matters. A Content Security Policy whose script-src doesn't include 'unsafe-eval' makes eval and new Function throw, so even a careless future change can't run config strings. Trusted Types, enforced through CSP, make innerHTML and similar sinks refuse plain strings in browsers that support them.

React helps too. It escapes text children by default, and React 19 replaces a javascript: URL in props such as href with one that only throws an error (React 16.9 to 18 only warn). Treat all of these as safety nets, not the design.

Check your understanding · Exercise 5

Marketing wants rich text in promo blocks: bold, italic, and links. Which design is safest while still meeting the need? Pick one.

Server-driven UI and the old-client problem

Server-driven UI (SDUI) takes the idea to whole screens. Instead of a CMS feeding a few marketing pages, a backend service builds the layout of a screen for each request: which sections, in which order, with which data already filled in. The client becomes a capable but generic renderer of sections.

The pull is strongest on native mobile apps, where every code change waits for store review and for users to update. With SDUI, a team can reorder a screen, run an experiment on layout, or launch in a new market the same afternoon, on every app version that already knows the sections involved.

How some companies describe it

  • Airbnb described its Ghost Platform, a server-driven system shared by web, iOS, and Android, on its engineering blog in 2021. As described there, screens are built from typed sections defined in a shared GraphQL schema, and user interactions are expressed as named actions the clients know how to perform. Details change over time, so treat this as the shape of the idea, not a spec.
  • Lyft's Bikes and Scooters team has written about moving its screens to server-driven UI, so that UI changes depend less on app releases.
  • Shopify Online Store 2.0 themes store each page template as JSON that lists sections and blocks with their settings. Merchants rearrange them in the theme editor; the theme's section files are the registry.
  • Headless CMSs such as Contentful, Sanity, and Storyblok all push the same model for websites: content types as the schema, blocks as the registry, the site as the renderer.

The old-client problem

On the web, everyone gets the latest bundle on their next page load (apart from long-lived tabs). On mobile, you may have five or more app versions in active use at once. When the server starts sending a new section type, say VideoHero, every app built before VideoHero existed receives a type it can't render.

There are three ways to handle this, and mature systems use all three in layers: the server avoids sending what the client can't draw, the config carries a fallback, and the client skips anything it still doesn't know.

A sequence diagram with three columns: App 5.0, the screen API, and App 5.2. App 5.0 asks for the home screen and sends its capabilities, Hero version 2 and ProductRow version 3; it gets Hero and ProductRow. App 5.2 asks with capabilities that also include VideoHero version 1, and gets VideoHero and ProductRow.App 5.0Screen APIApp 5.2caps: Hero@2, Row@3Hero, ProductRowcaps: + VideoHero@1VideoHero, ProductRoweach app gets only what it can draw
Both apps ask for the same screen and list what their registry can draw. The server builds each answer from those capabilities, so the old app gets a complete page, not a gap.

Decision How does the server know what a client can render?

Client skips unknown types

The server sends the same config to everyone.

The renderer's fallback drops unknown nodes and reports them.

Pros

  • Zero coordination; works from day one.
  • Always needed as the last layer anyway.

Cons

  • Old clients get gaps where new sections should be, sometimes the most important one.
  • The server can't adapt the rest of the layout around the gap.

Server maps app version to supported types

The client sends its app version in a header.

The server keeps a table of which section types and versions each app version supports.

Pros

  • Simple for clients: they send one header.
  • Server can substitute an older section for an older app.

Cons

  • The table drifts from reality; one wrong row and an app version gets blank sections.
  • Web builds, native builds, and feature flags multiply the table.

Capability negotiation

The client sends the list of section types and versions its registry supports, for example Hero@2, VideoHero@1, ProductRow@3, or a hash of that list.

The server builds each screen using only those capabilities.

Pros

  • Exact: the list comes from the registry the app actually shipped.
  • Works the same for web, iOS, Android, and partial rollouts.

Cons

  • A longer request (send a hash and cache the full list server-side to keep it small).
  • Every section needs a plan for clients that lack it.

What we pick: Use capability negotiation with a registry-generated list (or its hash) as the main mechanism, keep per-node fallbacks for sections that matter, and always let the client skip what it still doesn't know. For web-only, CMS-driven pages, client-side skipping plus validation is usually enough, because every user has the latest bundle within one page load.

Fallbacks inside the config

A node can carry its own plan B: { "type": "VideoHero", "props": { ... }, "fallback": { "type": "Hero", "props": { ... } } }. A client that doesn't know VideoHero renders the Hero with a still image instead. The renderer change is one line: if the type is unknown and the node has a fallback, render the fallback.

Versioning the config itself

Saved configs outlive the code that wrote them. A home page saved in 2024 must still render after the schema has changed three times. Put a version on every config and keep a chain of small migrations, each a pure function that upgrades one version to the next. On read, run the chain until the config is current, then validate.

A timeline of three config versions. Version 1, saved in 2024, has a cta as plain text. Version 2, saved in 2025, has a cta with label and href. Version 3, today, has a cta with label and a named action. Migrate arrows lead from each version to the next, and version 3 goes on to validate and render.config v1cta: textsaved 2024migrateconfig v2cta: label, hrefsaved 2025migrateconfig v3cta: label, actiontodayvalidatethen render
Each migration is a small pure function that knows one step. Chained, they bring a config saved years ago up to today's shape before the schema sees it.
  • Prefer additive changes. A new optional prop or a new block type needs no migration and no coordination.
  • Never change the meaning of an existing prop. If size: "lg" should now mean something else, add a new prop or a new type version (Hero@3).
  • Retire types slowly. Stop sending a section only when the app versions that need it have dropped below an agreed share of sessions (for example, under 1 percent), and keep its migration for saved configs.
  • Test migrations with real old configs. Keep a folder of saved configs from each version as fixtures and run them through the chain in CI.

Check your understanding · Exercise 6

Your iOS app 5.2 adds a VideoHero section. 30 percent of users are still on 5.0 and 5.1. The home screen API starts returning VideoHero as the first section for everyone. What do 5.0 users see, and what is the better rollout? Pick one.

Performance, testing, and previews

Config-driven pages have two performance traps that hard-coded pages don't. The registry can quietly pull every block into every page, and the config adds a network step before the app knows what to render. Both are fixable, but only if you design for them.

Trap 1: the whole registry in every bundle

A static registry imports every block. The bundler can't tree-shake any of them (drop the ones a page doesn't use), because which blocks are used is decided at runtime by data. A registry of 30 blocks, including a map, a video player, and a chart library, can easily add a few hundred KB of compressed JavaScript to a page that only shows a hero and two product rows.

A bar chart of block JavaScript for a page that shows a hero and two product rows, with illustrative numbers drawn to scale. A static registry of all 30 blocks: 320 KB. Lazy heavy blocks with prefetch from the config: 110 KB. Server components for the blocks that need no interactivity: 45 KB.block JavaScript for one light page, KB compressed (illustrative)static registry, all 30 blocks320 KBlazy heavy blocks, prefetched110 KBserver components for static blocks45 KB
Illustrative numbers, but the shape is typical: a static registry makes every page pay for every block, because which blocks are used is decided by data at runtime, where the bundler can't see it.

The fix is a lazy registry for anything heavy, plus prefetching from the config: as soon as the config arrives, start import() for every type it names, in parallel. Otherwise you get a waterfall: fetch config, render, discover a lazy block, fetch its chunk, then fetch its data.

ts
type Loader = () => Promise<{ default: ComponentType<any> }>;
const loaders = new Map<string, Loader>([
  ["Map", () => import("./blocks/MapBlock")],
  ["Video", () => import("./blocks/VideoBlock")],
]);
// Registry entries for heavy blocks, built from the same loaders.
const lazyBlocks = new Map([...loaders].map(([type, load]) => [type, lazy(load)] as const));

function prefetchBlocks(node: ConfigNode) {
  loaders.get(node.type)?.(); // starts the download now
  node.children?.forEach(prefetchBlocks);
}
Walk the config once and start every chunk it needs before rendering. A module is fetched only once, so when the lazy entry later calls the same loader, it reuses the download already in flight.

Trap 2: the config round trip

On a client-rendered page, the browser downloads the app, then fetches the config, then fetches the data each block needs. That is three round trips before the hero appears. Three changes remove most of it:

  • Render on the server. With server-side rendering (SSR) or React Server Components, the server reads the config and sends HTML. With Server Components, blocks that don't need interactivity ship no block JavaScript at all.
  • Resolve data on the server. Let the page service fill in deals:today and send the products inside the config response (the backend-for-frontend pattern), instead of each block fetching on the client.
  • Cache the config. Configs change at publish time, not per request (unless personalised). Serve them from a CDN with a short max-age and stale-while-revalidate, purge on publish, and bundle a default config into native apps for the first launch and offline.

Reserve space for each block while it loads. A lazy block that pops in pushes everything below it down, which hurts Cumulative Layout Shift. Each adapter can declare a placeholder height, or the config can carry an aspect ratio for media blocks.

Testing configs

  • Unit tests for the renderer, the schema, every migration, and the rule evaluator. They are pure functions; Node's test runner is enough.
  • Fixture pages: one config per block type with typical, minimal, and extreme props (longest title, zero items, right-to-left text). Render them in Storybook or a test page.
  • Visual regression tests on the fixture pages catch a block that breaks at 320 px wide or with a long German title.
  • Contract tests: for each supported client version, validate what the page service returns against that version's schema. This catches the server sending VideoHero to a client without it.
  • Accessibility lint on configs: images need alt text, links need text, a page has one top-level heading and levels don't skip. The components are accessible; the arrangement must be too.

Previews and safe publishing

Editors need to see a change before real users do. Give every draft a preview URL that renders the unpublished config with the production renderer (Next.js calls this draft mode). Show validation errors inline in the preview, not only in a log. Publish through the same gates as code: validate, then roll out gradually or behind a flag for risky pages, and keep a one-click rollback to the previous version of the config.

Check your understanding · Exercise 7

A config-driven landing page has a slow Largest Contentful Paint. The trace shows: app JS, then a config request, then a chunk for HeroBlock, then the hero image. Which change helps most? Pick one.

Form builders, CMS pages, and knowing when to stop

Two uses of config-driven UI are so common they deserve their own look: forms and CMS pages. Then comes the most important judgment call of the whole topic: when the config has grown into something it shouldn't be.

Form builders

Forms are the best fit for config there is. Every field is one of a dozen types (text, email, number, select, date, checkbox, file), the layout is mostly a column, and the rules are well known: required, min, max, pattern. Insurance quotes, onboarding questionnaires, and admin tools often have hundreds of forms that differ only in fields.

The usual design uses JSON Schema for the data (types, required fields, limits) and a separate UI config for presentation (labels, order, widgets, help text); react-jsonschema-form popularised this split. The big win is that the same JSON Schema validates on the client for fast feedback and on the server for safety, so the two can't drift apart.

  • Conditional fields ("show company name if account type is business") use a declarative rule like { "showIf": { "field": "accountType", "equals": "business" } }, never an expression string.
  • Hidden fields still validate on the server. A field hidden by a rule must not be required, and the server must ignore or reject values for fields the user couldn't see.
  • Accessibility comes from the field components: every field registered in the form registry already has its label, error message wiring (aria-describedby), and focus handling.

CMS-driven pages

In a headless CMS, the content model is the schema: a "Landing page" has a list of blocks, and each block type (hero, feature grid, testimonial, FAQ) has fields. Engineers build one adapter component per block type; editors assemble pages. Keep the block list short and opinionated. Thirty nearly identical hero variants are a design-system problem showing up in the CMS.

The inner-platform effect

The inner-platform effect is what happens when a system is made so configurable that it becomes a poor copy of the platform it runs on. The config gains variables, then loops, then expressions, then event handlers, then a way to call APIs. You have built a programming language with no types, no debugger, no tests, no code review, and one person who understands it.

Watch for these signs: engineers, not editors, write most configs; configs are copied and edited by hand because the editor can't express what's needed; bugs are found by reading JSON; a config change needs a code review anyway; the schema has a script, expression, or customCode field. When you see two or three of these, move that part back into code and expose a single higher-level block instead.

A matrix of five surfaces against three columns: changes often, little logic, and the verdict. Home feed and promos: yes, yes, config. Landing pages: yes, yes, config. Many simple forms: yes, simple rules, config. Checkout flow: rarely, no, code. Rich editor: no, no, code.changes oftenlittle logicverdicthome feed, promosyesyesconfiglanding pagesyesyesconfigmany simple formsyesrulesconfigcheckout flowrarelynocoderich editornonocode
Config fits where layouts change often and logic is thin. Where the logic is the product, as in checkout or an editor, keep it in code and expose at most one block.

Decision How much of a screen should be driven by config?

Hard-coded screen

Components and layout live in code.

Text may still come from a CMS or translation files.

Pros

  • Full type safety, normal tooling, easiest to debug.
  • No schema, no versioning, no registry to maintain.

Cons

  • Every layout change needs a release.
  • Can't vary the layout per user or market without code.

Config-driven regions

Most of the screen is code; one or two regions (the home feed, a promo slot) render blocks from config.

A small registry of blocks for those regions.

Pros

  • Fast changes where they matter, normal code everywhere else.
  • Small allowlist, small schema.

Cons

  • Two ways of building UI on the same screen.
  • Teams may push to grow the region over time.

Fully server-driven screens

The server sends every section of the screen; the client is a generic renderer.

Capability negotiation, fallbacks, and migrations are required.

Pros

  • Change any screen on every platform without a release.
  • One layout engine for web, iOS, and Android.

Cons

  • A platform team to build and run it.
  • Harder debugging, offline needs bundled defaults, and complex interactions still need code.

What we pick: Default to hard-coded screens with config-driven regions where content changes weekly: home feeds, promos, landing pages, forms. Go fully server-driven only for native apps with many releases in the wild and a team that can own the platform. Flows with rich interaction (editors, checkout logic, maps) stay in code.

Check your understanding · Exercise 8

Your form config now has "computed": "price * (1 - discount / 100)" fields, "onChange" handlers that call named APIs, and loops to repeat fields. Engineers write most configs. What is the best next step? Pick one.

Using it in a system design interview

Config-driven UI comes up whenever the prompt says "marketing needs to change it often", "different per country", "A/B test the layout", "many forms", or "mobile apps that can't wait for releases". It is also a common follow-up on design-a-home-feed or design-a-form-builder questions.

Draw the five parts in a row: config source, schema check, registry, renderer, components. Then say where each check happens and what happens on failure. That single diagram answers most follow-ups before they're asked.

What to say, in order

  1. Shape: a JSON tree of nodes with id, type, props, children; a version on the page.
  2. Registry as allowlist: adapters per block type, actions and data sources by name, never code.
  3. Validation: one schema, checked in the editor, at publish, in CI, and at runtime; per-block failure with reporting; last known good fallback.
  4. Versioning: migrations for saved configs; capability negotiation and per-node fallbacks for old clients.
  5. Performance: server rendering from config, data resolved on the server, lazy heavy blocks prefetched from the config, CDN-cached configs.
  6. Limits: what stays in code, and the inner-platform warning signs.

How deep to go by level

LevelWhat a strong answer shows
JuniorExplains JSON to registry to components, uses stable keys, skips unknown types, and says configs are untrusted (no eval, no raw HTML).
Mid-levelAdds schema validation with per-block failure, named actions, lazy blocks with prefetch, preview and rollback for editors.
SeniorDesigns versioning (migrations, capability negotiation, fallbacks), server-side resolution of rules and data, contract tests per client version, and draws the line before the inner-platform effect.

Think first

The interviewer asks: "Why not just send HTML from the server, like a normal website?" How do you answer?

Summary

  • The config names things; the code draws them. A config can only arrange components, actions, and data sources the app already ships.
  • The registry is an allowlist. Register adapters, look types up in a Map, and give every node a stable id for its React key.
  • Configs are untrusted input. Validate with one schema in the editor, at publish, in CI, and at runtime, and fail per block with a report.
  • Never eval, new Function, or dangerouslySetInnerHTML from config. Resolve rules on the server or use a few named conditions; use structured rich text.
  • Plan for old clients from day one: capability negotiation, per-node fallbacks, and skipping unknown types, plus versioned configs with one-step migrations.
  • Keep it fast: render from config on the server, resolve data there, cache configs on the CDN, and lazy-load heavy blocks with prefetch.
  • Stop before the inner-platform effect: when configs grow logic, move it back into code and expose one higher-level block.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Design the home page of a shopping app so marketing can change its layout without a release. Walk me through it.

Question 2

Your native app has five versions in the wild. How do you add a new section type without breaking old versions?

Question 3

A teammate proposes a visibleIf field holding a JavaScript expression, evaluated with new Function. What do you say?

Question 4

How do you keep a config-driven page fast?

Question 5

When would you argue against config-driven UI?

Practice it

These design problems lean on config-driven ui.

References

Read next