Thoughtworks Technology Radar Vol 34 (April 2026) puts Server-driven UI in the Trial ring again. The pattern: the server returns JSON describing a UI; the client interprets that JSON into widgets. Airbnb, Lyft, Uber and others have been doing this for years to skip app-store review cycles — but Radar's pitch is the lightweight version, not the proprietary "god-protocol" that swallows mobile teams. To get a feel for the lightweight version, I wrote a 500-line vanilla JS interpreter for 12 widget types: paste JSON, see the layout render.
🌐 Demo: https://sen.ltd/portfolio/server-driven-ui/
📦 GitHub: https://github.com/sen-ltd/server-driven-ui
Why mobile teams reach for SDUI
Three motivations stack:
- Skip the App Store / Play Store review cycle. UI changes ship as a new JSON response. No new binary.
- One design for iOS, Android, web. Each client interprets the same spec into native widgets. The screen layout lives in one place.
- A/B test layouts with zero code change. Server returns variant A's spec to one cohort, variant B's to another. The app doesn't know.
The cost is sharp: every widget type the server can emit must already exist in every client. Copy and layout are server-controlled; widget vocabulary is locked to whatever's in the deployed app. The spec is a versioned contract — bumping it is the same shape of work as bumping a public API.
The spec
A minimal but useful widget set:
export const WIDGETS = {
vstack: { props: { spacing: "number?", align: "string?" }, hasChildren: true },
hstack: { props: { spacing: "number?", align: "string?" }, hasChildren: true },
text: { props: { content: "string", style: "string?" }, hasChildren: false },
heading: { props: { content: "string", level: "number?" }, hasChildren: false },
button: { props: { label: "string", variant: "string?", action: "string?" }, hasChildren: false },
card: { props: { title: "string?", variant: "string?" }, hasChildren: true },
image: { props: { url: "string", alt: "string?", aspect: "string?" }, hasChildren: false },
badge: { props: { label: "string", tone: "string?" }, hasChildren: false },
divider: { props: {}, hasChildren: false },
spacer: { props: { size: "number?" }, hasChildren: false },
list: { props: { spacing: "number?" }, hasChildren: true },
link: { props: { label: "string", url: "string" }, hasChildren: false },
};
The ? suffix marks optional props. hasChildren distinguishes leaf widgets from containers. The catalog is the single source of truth — the validator reads required props from it, the renderer dispatches on its type keys, and the in-app reference panel is generated from the same object.
A concrete spec — feed list:
{
"type": "vstack",
"spacing": 12,
"children": [
{ "type": "heading", "content": "Recent activity", "level": 2 },
{
"type": "list",
"spacing": 10,
"children": [
{
"type": "card",
"children": [
{
"type": "hstack",
"spacing": 10,
"align": "center",
"children": [
{ "type": "image", "url": "/avatars/a.png", "alt": "Alice", "aspect": "1/1" },
{
"type": "vstack",
"spacing": 2,
"children": [
{ "type": "text", "content": "Alice merged PR #482", "style": "bold" },
{ "type": "text", "content": "2 minutes ago", "style": "muted" }
]
}
]
}
]
}
]
}
]
}
Standard "avatar + two lines of text, repeated as cards" feed. The server can swap avatar URLs, change align to "start", switch from vstack to hstack — all without a redeploy of the client.
The validator
Validate before rendering. Errors come back with paths:
export function validateSpec(spec, path = "$") {
const issues = [];
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
issues.push({ path, message: "node must be an object" });
return issues;
}
if (typeof spec.type !== "string") {
issues.push({ path, message: "node missing 'type' string" });
return issues;
}
const def = WIDGETS[spec.type];
if (!def) {
issues.push({ path, message: `unknown widget type: ${spec.type}` });
return issues;
}
for (const [prop, kind] of Object.entries(def.props)) {
const required = !kind.endsWith("?");
const baseKind = kind.replace(/\?$/, "");
const value = spec[prop];
if (value === undefined) {
if (required) issues.push({ path: `${path}.${prop}`, message: `required prop missing` });
continue;
}
if (!typeOk(baseKind, value)) {
issues.push({ path: `${path}.${prop}`, message: `expected ${baseKind}, got ${actualKind(value)}` });
}
}
if (def.hasChildren && Array.isArray(spec.children)) {
spec.children.forEach((child, i) => {
issues.push(...validateSpec(child, `${path}.children[${i}]`));
});
} else if (!def.hasChildren && spec.children !== undefined) {
issues.push({ path: `${path}.children`, message: `widget '${spec.type}' does not accept children` });
}
return issues;
}
Errors come back JSONPath-style: $.children[1].content — required prop missing. This shape is intentional: it's directly loggable, directly diff-able, and directly actionable for the server team that emitted the broken spec. SDUI in production needs that feedback loop — the client receives a bad spec, the client logs the path, the server team fixes the emit and ships a new response.
The renderer
A dispatcher table keyed on type:
export function render(spec) {
const fn = RENDERERS[spec.type];
if (!fn) {
const fallback = document.createElement("div");
fallback.className = "sdui-unknown";
fallback.textContent = `[unknown: ${spec.type}]`;
return fallback;
}
return fn(spec);
}
const RENDERERS = {
vstack(spec) {
const el = document.createElement("div");
el.className = "sdui-vstack";
if (spec.spacing != null) el.style.gap = `${spec.spacing}px`;
if (spec.align) el.style.alignItems = mapAlign(spec.align);
for (const child of spec.children || []) el.appendChild(render(child));
return el;
},
// 11 more
};
Unknown widgets must not silent-fail. The worst SDUI bug is "server shipped a widget the client doesn't know," because silent skip means part of the screen just disappears and nobody notices until support tickets arrive. Render [unknown: <type>] instead — visible to users and developers, loggable, easy to roll back.
In the tool the sdui-unknown class is dashed red. In production it'd log to your error pipeline.
Avoiding the "god-protocol" trap
Radar's writeup explicitly warns against the evolution path where the widget catalog grows into a few hundred bespoke types, each with its own ad-hoc options. Airbnb's early SDUI ended up there; their later iterations consolidated. The principle:
- Grow combinations, not catalog entries. New layouts should come from composing existing widgets, not from a new widget.
- No "flexible card" or "universal container." Those god-widgets compress the type system back to JSON-blob soup.
- Adding a widget = API version bump. Server can't emit it until every supported client has shipped a release that knows about it.
The tool ships 12 widgets. That's enough for promo / feed / pricing / empty-state — the four presets cover the patterns. A healthy production catalog sits somewhere in the 20–30 range, not 200.
21 tests
Categorised:
- happy paths (3): text / vstack / button
- required prop errors (3): text, button, link
- type errors (3): wrong prop type, unknown widget type
- children handling (3): leaf rejects children, children-must-be-array, nested error path
- top-level shape (3): null / array / missing type
- metrics (4): countNodes + maxDepth
- catalog integrity (2): every widget has props + hasChildren
The "nested error path" test is the load-bearing one in production: it pins down that $.children[1].content form, which is what server-team incident reports rely on. If you ever regress to bare content — missing errors, validators in CI catch it before the spec consumer does.
Architecture
spec.js ← validator + metrics + WIDGETS catalog (DOM-free, 21 tests)
render.js ← One renderer per type, dispatcher on spec.type
presets.js ← 4 sample specs
app.js ← UI glue (spec → validate → render, metrics display)
spec.js doesn't touch the DOM. render.js only touches document.createElement. app.js glues them. To add a widget: one catalog entry, one renderer function, one or two tests. Three small edits.
Try it
- Demo: https://sen.ltd/portfolio/server-driven-ui/
- GitHub: https://github.com/sen-ltd/server-driven-ui
Pick "Feed list," edit a content to misspell a field name, watch the validator point to the exact $.children[N].something path. Then pick "Empty state" to see how minimal a useful SDUI screen can be.
Takeaways
- SDUI's three motivations — bypass app-store review, one design across platforms, A/B test without code changes — stack rather than substitute for each other.
- Catalog as single source of truth keeps validator, renderer, and reference docs structurally consistent.
- Validate with JSONPath-style errors: directly loggable, directly fixable on the server side.
- Never silent-fail on unknown widgets. Render a visible placeholder; let the error reach the people who can fix it.
- Resist the god-protocol. Grow combinations of small widgets, not the widget catalog itself. New widget = API version bump, treated as the rare event it should be.
This is OSS portfolio #250 from SEN LLC (Tokyo), the fourth entry in the "Try the Tech Radar" series. Previous: #249 Schema → LLM Prompt, #248 Markdown → Typst, #247 TOON converter. Next up: Mutation testing. https://sen.ltd/portfolio/

Top comments (0)