Thoughtworks Technology Radar Vol 34 (April 2026) の Trial 枠に Server-driven UI が "戻ってきている"。サーバが JSON で UI を返し、クライアントが widget としてレンダリングするパターン。Airbnb / Lyft / Uber のような大手モバイルチームが app store の review cycle をスキップするために何年も使っているが、Radar が改めて Trial に置いたのは proprietary "god-protocol" でない、ライト級のパターン が成熟してきたから。500 行 vanilla JS で JSON spec を貼ると 12 種類の widget が render される live editor を書いて感触を見てみた。
🌐 Demo: https://sen.ltd/portfolio/server-driven-ui/
📦 GitHub: https://github.com/sen-ltd/server-driven-ui
なぜモバイルチームはこれを使うのか
3 つの動機が積み重なる:
- App Store / Play Store の review cycle をスキップ。UI 変更を JSON レスポンスのデプロイで出せる。新しい binary は要らない。
- iOS / Android / Web で 1 つの設計。各プラットフォームクライアントは同じ JSON spec を native widget に翻訳する。画面デザインが 1 箇所 にある。
- A/B テストがコード変更ゼロ。サーバが cohort A に variant A の spec を、cohort B に variant B の spec を返す。アプリは何も知らない。
代償は明確: サーバが emit する全 widget type はクライアントに既に存在していないといけない。レイアウトとコピーは自由に変えられるが、新 widget は新 release が必要。Radar が "ライト級なものから始めろ" と書いているのはここの規律の話。spec は versioned contract、bumping は public API の bump と同じ shape の仕事。
spec の形
最小限の widget セットで実用的なレイアウトが組める形にする:
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 },
};
? は optional、無印は required。hasChildren がそのまま leaf vs container の判別。catalog が single source of truth で、validator も renderer も in-app reference panel もここを読む。
実例: 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" }
]
}
]
}
]
}
]
}
]
}
これで「avatar + 2 行のテキスト × 3 カード」の標準的なフィード行が出来上がる。サーバが avatar URL を切り替えても、配置を align: "start" に変えても、再 deploy 不要。
バリデータの中身
spec を render する前にバリデートする。エラーは path 付きで:
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;
}
const type = spec.type;
if (typeof type !== "string") {
issues.push({ path, message: "node missing 'type' string" });
return issues;
}
const def = WIDGETS[type];
if (!def) {
issues.push({ path, message: `unknown widget type: ${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 '${type}' does not accept children` });
}
return issues;
}
エラーは $.children[1].content — required prop missing のような JSONPath 風で返す。サーバ側に直接フィードバックできる形式 にしておくのが SDUI の運用では効く — クライアントが受け取った spec が壊れていたら、その path をログに送って次の deploy で修正、というループが成立する。
レンダラの分岐
renderer は dispatcher 関数を type → fn の Map で持つだけ:
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 widget は静かに失敗しない で [unknown: <type>] を表示する。SDUI で最も怖いのは「クライアントが知らない widget type をサーバが返す」状況 (= サーバ側が先走って deploy されたケース)。クライアントが silent skip すると 画面の一部が消える が、明示的に "[unknown]" を出すと ユーザにも開発者にも見える形で残る。Logging に流せばロールバックの判断もしやすい。
これが本ツールの sdui-unknown クラス。CSS で赤い破線枠が付くようにしてある。
"god-protocol" の罠と avoid
Radar の本文がわざわざ警告しているのが "god-protocol" — 何でも表現できる proprietary な仕様 に発展してしまうケース。Airbnb の SDUI が初期に陥った状況で、widget が数百種類になって誰もメンテできなくなる。
回避策は widget セットを意図的に小さく保つ:
- 出来ることを増やすより、今ある widget の組み合わせで何を表現できるか を増やす
- 「flexible card」「universal container」みたいな god-widget を作らない
- 新 widget の追加は API バージョン bump 同等 として扱う
このツールは 12 widget。これだけで promo / feed / pricing / empty state は全部組める。実運用でも 20-30 個に収まるのが健全な範囲。
21 件のテスト
カテゴリ:
- happy paths 3 件 — text / vstack / button
- required prop errors 3 件 — text / button / link
- type errors 3 件 — wrong prop type / unknown widget
- children handling 3 件 — leaf に children / array でない / nested error path
- top-level shape 3 件 — null / array / missing type
- metrics 4 件 — countNodes / maxDepth
- catalog integrity 2 件 — props 必須 / widget 数
特に "nested error path" テスト は実運用で効く: validator が $.children[1].content のような正確な path を返すことを test で固定しているので、サーバチームがエラーを見て一発で修正箇所を特定できる。
アーキテクチャ
spec.js ← validator + metrics + WIDGETS catalog (DOM 非依存、21 tests)
render.js ← type → renderer dispatcher
presets.js ← 4 種の sample spec
app.js ← UI グルー
spec.js は DOM を一切触らない。render.js は document.createElement だけ呼ぶ。app.js は両者を繋ぐだけ。新 widget 追加は catalog にエントリ 1 行 + render 関数 1 つ + テスト 1-2 件 で完結する。
まとめ
- Server-driven UI は app store review をスキップ + 1 つの設計を多 platform に push + コード変更ゼロでの A/B test の 3 つで採用される
- god-protocol 化が最大の罠。widget セットを小さく保つ規律が必要
- catalog を single source of truth に置くと validator / renderer / reference panel が自動で一貫する
- バリデータの error は path 付き にしてサーバへのフィードバックループを作る
-
unknown widget は silent fail させない —
[unknown: <type>]を出して可視化 - 12 widget × vstack/hstack の組み合わせで promo / feed / pricing / empty state は全部組める
リポジトリ: https://github.com/sen-ltd/server-driven-ui
このツールは弊社の OSS ポートフォリオ #250 として作成しました。Tech Radar 試してみた シリーズ第 4 弾。前回は #249 Schema → LLM Prompt、#248 Markdown → Typst、#247 TOON コンバータ。次回は Mutation testing 予定。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/
