DEV Community

SEN LLC
SEN LLC

Posted on

Try the Tech Radar #4 — Server-driven UI in a 12-Widget Vanilla JS Interpreter

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

Screenshot

Why mobile teams reach for SDUI

Three motivations stack:

  1. Skip the App Store / Play Store review cycle. UI changes ship as a new JSON response. No new binary.
  2. One design for iOS, Android, web. Each client interprets the same spec into native widgets. The screen layout lives in one place.
  3. 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 },
};
Enter fullscreen mode Exit fullscreen mode

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" }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

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)