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.
{
"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" } }
]
}
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?
Because the speed only applies to changes the components already support. A new kind of block still needs code and a release, and every prop you expose becomes a contract you can't easily break.
Config also moves bugs from compile time to runtime: a typo in a prop name won't fail a build unless you add validation. And a config language that grows conditions, loops, and expressions slowly turns into a worse programming language. The rest of the lesson shows where the line usually falls.
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.
- The config: plain data, usually JSON. A tree of nodes, each with a
type,props, and maybechildren. It is written by people (editors, product managers) or produced by a service. - 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.
- The registry: a map from each type name to a real component, such as
Heroto theHeroReact component. It is also the allowlist: if a name is not in the map, nothing renders for it. - 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.
- 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.
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.
| Question | Hard-coded UI | Config-driven UI | Server-rendered HTML |
|---|---|---|---|
| Who decides the layout | Code in the app | Data from a CMS or API | Server templates |
| Change without a release | No | Yes, within known components | Yes, for web only |
| Can the payload run code | Not applicable | No, if you never eval | Yes, the browser runs it |
| Works for native apps | Yes | Yes, same config | No |
| Type safety | Compile time | Runtime schema checks | Template 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.
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;
}
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".
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
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
idand use it as the Reactkey. 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 theObjectfunction, which React would then try to call as a component. Use aMap,Object.hasOwn, or an object created withObject.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
idandtype, 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.
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);
});
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.
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.
// 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],
]);
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?
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.
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>;
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) orhttps: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
Carouselhas 2 to 12 slides", "a form has exactly one submit". Zod'srefinehandles 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.
- 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. - 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.
- 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.
- 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.
Validation report: no issues.
App 5.2 (knows VideoHero): 4 blocks rendered.
- Hero: "Friday sale" with image /img/sale.webp, button "Shop now" to /sale
- ProductRow: 8 products from deals:today
- CategoryGrid: 4 columns
- Newsletter: "Get the deals first"
App 5.0 (older build): 4 blocks rendered.
- Hero: "Friday sale" with image /img/sale.webp, button "Shop now" to /sale
- ProductRow: 8 products from deals:today
- CategoryGrid: 4 columns
- 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.
The five doors to close
| Door | What it looks like in a config | What to do instead |
|---|---|---|
| Expressions | "visibleIf": "user.cartTotal > 50" run with eval or new Function | Resolve rules on the server, or use a tiny allowlisted rule format the app interprets |
| Raw HTML | "body": "<p>Hi</p>" rendered with dangerouslySetInnerHTML | Structured rich text (a JSON tree of paragraphs, marks, links) rendered by your components |
| Links | "href": "javascript:..." or a lookalike domain | Schema 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.
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);
});
}
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.
Decision How does the server know what a client can render?
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.
- 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.
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.
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);
}
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:todayand 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-ageandstale-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
VideoHeroto 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.
Decision How much of a screen should be driven by config?
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
- Shape: a JSON tree of nodes with
id,type,props,children; a version on the page. - Registry as allowlist: adapters per block type, actions and data sources by name, never code.
- Validation: one schema, checked in the editor, at publish, in CI, and at runtime; per-block failure with reporting; last known good fallback.
- Versioning: migrations for saved configs; capability negotiation and per-node fallbacks for old clients.
- Performance: server rendering from config, data resolved on the server, lazy heavy blocks prefetched from the config, CDN-cached configs.
- Limits: what stays in code, and the inner-platform warning signs.
How deep to go by level
| Level | What a strong answer shows |
|---|---|
| Junior | Explains JSON to registry to components, uses stable keys, skips unknown types, and says configs are untrusted (no eval, no raw HTML). |
| Mid-level | Adds schema validation with per-block failure, named actions, lazy blocks with prefetch, preview and rollback for editors. |
| Senior | Designs 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?
For a web-only page, server-rendered HTML is often the right answer, and a config-driven page should usually be rendered to HTML on the server anyway.
Config wins when the same screen must render on native apps (which can't use your HTML), when blocks need rich client behaviour from tested components, and when you want the payload to be data the client interprets rather than markup it executes. The config is also easier to validate, version, and diff than HTML.
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 stableidfor 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, ordangerouslySetInnerHTMLfrom 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.
The page is a JSON config from a page service: a version, then a tree of nodes, each with a stable id, a type, props, and optional children. The app has a registry that maps each type to an adapter component built on the design system, plus registries for named actions (navigate, addToCart) and data sources (deals:today). Nothing in the config is code.
One Zod schema describes every block. It feeds the CMS editor as JSON Schema, runs at publish, runs in CI against all saved configs, and runs at runtime per block, so one bad block is dropped and reported while the page renders. If the whole config is broken, the app uses the last known good copy or a bundled default.
For speed I render on the server from the config, resolve data on the server, cache configs on the CDN and purge on publish, and lazy-load heavy blocks with prefetch from the config. Editors get draft previews and one-click rollback.
Question 2
Your native app has five versions in the wild. How do you add a new section type without breaking old versions?
Three layers. The client sends its capabilities, the list of section types and versions in its registry (or a hash of it), and the server builds the screen only from those. Sections that matter carry a fallback node of an older type. And the client renderer still skips anything unknown and reports it, as the last safety net.
Saved configs get a version and a chain of one-step migrations. Changes are additive where possible; a changed meaning gets a new type version. A type is retired only when the versions needing it fall below an agreed share of sessions, and contract tests check the server's output against each supported client schema.
Question 3
A teammate proposes a visibleIf field holding a JavaScript expression, evaluated with new Function. What do you say?
No: it turns every config into code, so anyone who can edit a config, or compromise the CMS, can run script in every user's browser. That is stored XSS at scale.
Most visibility rules depend on who the user is, which the server knows, so resolve them on the server and send only what each user should see. For rules that must run on the device, use a few named, tested conditions like cartTotalAtLeast that fail closed on unknown names. Back it up with a CSP whose script-src leaves out 'unsafe-eval'.
Question 4
How do you keep a config-driven page fast?
First, the registry: a static registry ships every block to every page, so heavy blocks become lazy and are prefetched as soon as the config names them. Second, round trips: render the config on the server, include the data blocks need, and cache the config on the CDN. Third, layout stability: each block reserves its space while loading.
The LCP block, usually the hero, stays in the main bundle and is rendered in the server HTML so the image request starts early.
Question 5
When would you argue against config-driven UI?
When the screen has rich interaction and logic (an editor, a complex checkout step), when engineers would be the only ones writing configs, or when the layout rarely changes. Then the schema, versioning, and runtime validation cost more than the release speed is worth.
I would also stop when the config starts growing expressions, loops, or handlers: that is the inner-platform effect. Move that logic into a tested component and expose it as one block.
Practice it
These design problems lean on config-driven ui.
- Design a product tourMedium
- Design an admin CMSMedium
- Design a gamified learning app like DuolingoMedium
- Design a drag-and-drop form builderHard
- Design Trello or JiraHard
- Design an analytics dashboardHard
- Design a no-code website builderHard
- Design a long insurance application formHard
- Design a 3D product configuratorHard
References
- Airbnb: A deep dive into Airbnb's server-driven UI system The Ghost Platform (2021): sections, screens, and actions in one shared GraphQL schema.
- Lyft: The journey to server-driven UI at Lyft Bikes and Scooters Why and how one product team moved its screens to SDUI.
- Inner-platform effect The anti-pattern a config language drifts into when it grows logic.
- Shopify: JSON templates A large, real config-driven system: page templates as JSON lists of sections and blocks.
- Zod: JSON Schema Turning a runtime schema into JSON Schema for editors and other languages.
- Understanding JSON Schema The standard most form builders and CMS tools use to describe data.
- react-jsonschema-form documentation A well-known form renderer that splits the data schema from the UI schema.
- React: lazy Lazy registry entries and the Suspense boundaries they need.
- MDN: CSP script-src Why a policy without
unsafe-evalmakesevalandnew Functionthrow. - MDN: Trusted Types API Locking down HTML sinks such as
innerHTML.
Read next
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.
- API contracts, versioning, and errorsThe written agreement between the frontend and the API: a typed schema both sides are checked against, rules for changing it without breaking old clients, and one error shape the UI can always understand, including for partial failures and safe retries.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Feature flagsSwitches in your code that turn features on or off for chosen users without a deploy: for gradual releases, experiments, emergency kill switches, and plan-based access, if you avoid flicker, leaked code, and a pile of dead flags.