Vercel Labs' json-render lets a model generate real interfaces: you define a catalog of components, the model emits a JSON spec constrained to it, and a renderer draws the result. That is the structural half of a bound generator, shipped as a library. This tutorial adds the other half, the adaptive design system: a behavior the screen serves, a brand the generator reads, a renderer contract you can run as code, a fallback that ships when generation fails, and evals that check twenty screens before a customer sees one.
This is Part II. Part I, the step-by-step tutorial, writes the governance files this one binds: commitments, a brand file, named behaviors. Run it first, or at least skim its artifacts. Plan on one weekend. The running example is Corredor's transfer confirmation, the screen where a wrong generation costs real money.
Why this pairing works: json-render's spec is JSON, and JSON is checkable. The moment a generated screen becomes a walkable tree instead of a stream of markup, every placement guarantee in the article stops being an aspiration and becomes a function. Here is the map between the library's vocabulary and the system's:
| json-render gives you | In system terms | What this tutorial adds |
|---|---|---|
| catalog + zod schema | A design system the generator can read; structural binding | Semantic props only, no style knobs, facts by state path |
| registry | Rendering, wired to your real components | Components that consume semantic tokens and nothing else |
| generated spec | The screen as an output, not a design | A behavior that says what the screen is for |
| schema validation | Checks the nouns are legal | A renderer contract that checks the promises are kept |
| actions | What the interface may do | Consent shape and authority on the risky ones |
| streaming patches | Progressive rendering | A gate so consent never renders before the fee |
One sentence to hold through all nine steps: the catalog can say a button exists; only a contract can say the fee is stated before the confirm button can be reached. The library checks structure. You will check promises.
Install the library and render one hardcoded spec, so every later step has a live surface to break:
npm install @json-render/core @json-render/react zod
Then the smallest loop, a catalog with one component, a registry that maps it to a real implementation, and a spec rendered by hand:
// catalog.ts
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: { props: z.object({ title: z.string() }), slots: ["default"] },
},
});
// page.tsx: StateProvider + VisibilityProvider wrap
// a Renderer that takes { spec, registry }.
Hardcode a spec with a root Card and confirm it draws. No AI yet. The point of this step is that the spec, a plain JSON object with a root id and an elements map, is the artifact everything else in this tutorial reads, checks, and falls back to.
Go deeperChapter 2, From Screens to Behaviors: the screen as an output. The spec is that idea, serialized.
The registry is where your real design system enters. Every component you register consumes semantic tokens, and the catalog exposes zero style props. No color, no spacing, no variant that names an appearance. If the generator cannot reach around the token layer, a thousand generated screens stay siblings:
Audit the registry components against /styles/tokens.css:
every color, space, radius, and type size comes from a
semantic token. Then audit catalog.ts: remove any prop
that names an appearance (color, size, variant:"blue").
A prop may name a meaning (tone:"warning") only if a
brand rule says when that meaning applies.
This is the same three-layer discipline as the article: primitives exist, semantic tokens mean, and the generator touches only the middle layer. In json-render terms, the model cannot emit a hex value, because no prop accepts one.
Go deeperChapter 4, One Source of Truth: the layer discipline, and the canvas, prototype, and product as three renderings of one repository.
Now build the real catalog for the confirmation screen. Two rules make it governable. First, name components by function, never appearance: a FeeLine survives a redesign, a BlueFeeCard does not. Second, and this is the rule that pays for the whole tutorial: facts arrive by state path, never by generated prose. The model composes the screen; it does not get to write the numbers.
// catalog.ts, Corredor's confirm vocabulary
components: {
Stack: { props: z.object({ gap: z.enum(["flow","tight"]) }),
slots: ["default"] },
TransferSummary: { props: z.object({ recipientPath: z.string(),
amountPath: z.string() }) },
FeeLine: { props: z.object({ feePath: z.string() }) },
RateNote: { props: z.object({ ratePath: z.string(),
etaPath: z.string() }) },
WarnBanner: { props: z.object({ messageKey: z.string() }) },
ConsentButton: { props: z.object({ labelKey: z.string(),
amountPath: z.string() }) },
CancelLink: { props: z.object({}) },
},
actions: {
confirm_transfer: { params: z.object({ transferId: z.string() }),
description: "Requires explicit consent" },
cancel: { params: z.object({}) },
}
The props are the point: every one is a path into state (feePath) or a key into your tokenized copy (messageKey), resolved by the renderer's $state binding. The fee the customer sees comes from the rate object, resolved at render time. A generator that cannot write an amount cannot invent one, which retires the whole category of Air Canada failures at the schema layer.
Go deeperChapter 5, Semantic Components: nouns and verbs, and why a screen is where the two axes meet.
You wrote this file in Part I, step 5; if you skipped Part I, the shape below is complete enough to write now. The catalog says what can be drawn. The behavior says what the screen is for, and it is the file the next two steps compile from. One upgrade makes it renderer-ready: the allowedActions must name the catalog's actions exactly, so the two files can be checked against each other.
// behaviors/confirm-transfer.ts
export const ConfirmTransfer = {
intent: "Collect informed consent for a money transfer",
requiredFacts: ["amount", "rate", "fee", "arrivalWindow"],
allowedActions: ["confirm_transfer", "cancel"],
forbiddenClaims: ["guaranteed arrival time"],
repair: "RepairTransferDetails",
evals: ["consent-is-explicit", "fee-before-consent"],
};
Keep it surface-free. Nothing in this file mentions json-render, React, or a screen, because the same behavior also renders as a WhatsApp message and a voice turn. The catalog is one renderer's vocabulary for it.
Go deeperChapter 5 for the vocabulary; Chapter 7 for the same behavior wearing other channels' clothes.
Now the generation call. The catalog constrains the output shape; the briefing decides what a good screen is. Compile the system context from the files that already exist, in this order:
const system = [
read("skills/brand.md"), // taste as constraints
read("skills/commitments.md"), // what must always be true
describe(ConfirmTransfer), // the behavior being rendered
catalogDescriptions, // what each component is for
].join("\n\n");
// Then your model call of choice, constrained to the
// catalog's spec schema, streaming JSONL patches.
The prompt itself stays small, one paragraph of situation: which customer, which corridor, which profile. The context is the accumulated system. This is the design-time control plane in miniature: the brief is compiled, not typed.
Go deeperChapter 6, Brand as Governed Context: the brand file teaches the generator. The next step is the half that checks its work.
Here is the step no library ships, because it encodes your promises, not a schema. The spec is a tree; walk it. Every placement guarantee from the article becomes an assertion over traversal order:
// contracts/confirm-renderer.ts
// Walk spec.elements from spec.root, depth-first,
// collecting the visual order. Then assert:
fee-before-consent: index(FeeLine) is less than
index(ConsentButton)
cancel-within-reach: a CancelLink exists at depth
no deeper than ConsentButton
consent-is-explicit: ConsentButton.labelKey names the
action and binds amountPath
facts-are-bound: every *Path prop resolves against
the transfer's real state shape
no-orphan-warnings: if state.holdReason exists, a
WarnBanner precedes ConsentButton
onFailure: render templates/confirm-plain.json
Fifty lines of TypeScript, no model in the loop, runs in microseconds. The difference in kind from step 1's schema check is the whole point: the schema guarantees every element is a legal noun; the contract guarantees the sentence they form keeps the promise. Run it on every generated spec before the renderer sees it, and on every streamed patch set once the tree is complete.
Go deeperChapter 7, Every Surface, One Promise: renderer contracts as accessibility testing, placement and reachability, not presence.
The fallback is the best argument for spec-based UI: it is not a second codebase, it is one more spec, written by hand, checked into git, that satisfies the contract with zero generation:
// templates/confirm-plain.json
{ "root": "stack-1",
"elements": {
"stack-1": { "type": "Stack", "props": { "gap": "flow" },
"children": ["summary","fee","rate","consent","cancel"] },
"summary": { "type": "TransferSummary",
"props": { "recipientPath": "$state.transfer.recipient",
"amountPath": "$state.transfer.amount" } },
"fee": { "type": "FeeLine",
"props": { "feePath": "$state.transfer.fee" } },
...
} }
Wire onValidationFailure to render this file. It will be plain. It will also be correct, and correct-but-plain is the right floor for a screen that moves money. One more wire while you are here: streaming. Progressive rendering means elements draw as patches arrive, so gate the ConsentButton's visibility on the fee being mounted. The contract guarantees order in the finished tree; the gate guarantees it during the stream.
Go deeperChapter 7 on fallbacks; Chapter 8 on degrade-to-template as the runtime plane's floor.
Everything is now in place for the only scale of checking that matters with generators: many variants, mechanically judged.
Generate 20 specs for ConfirmTransfer across profiles
(first-time MX sender, repeat US sender, delayed transfer
with holdReason). For each spec, run:
1. the schema check (legal nouns)
2. the renderer contract (kept promises)
3. the behavior's evals from skills/evals.md
Report a table: variant, profile, pass/fail per check,
and the failing element id where it fails.
Then plant two violations: a spec with consent above the
fee, and a spec with a WarnBanner messageKey that does not
exist in the copy tokens. Confirm both are caught and
name which layer caught each.
The two planted violations should be caught by two different layers, the contract for the first, the schema-and-copy check for the second. That is the point of layering: each check is small, and together they leave no single place for a failure to hide. Every real failure this run surfaces is a decision: tighten the contract, sharpen the brand file, or add the missing noun. The fix always lands in a file.
Go deeperChapter 10, Evaluation-Driven Design: floors and dials, and the rubric as a first-class deliverable.
Last step: make forgetting impossible. Three hooks, then two lines in the log:
Add pre-merge hooks:
1. Any change to catalog.ts, behaviors/, or contracts/
reruns the 20-variant eval suite.
2. A new catalog component without a named behavior that
uses it fails the merge.
3. A new action without a consent shape in its behavior
fails the merge.
Then append to skills/decisions.md: why facts travel by
state path and not prose, and why the fallback is a spec
and not a second component tree.
And a sunset note, in the article's own spirit: the contract-walking code compensates for a generator that cannot yet be trusted with placement. If a future model holds the fee-before-consent instruction across a million generations, thin the contract to a sampled check and keep the commitment. The library will churn, the model will churn, and neither should take anything with it: your behaviors, contracts, evals, and fallback specs are files in your repository that would survive json-render's disappearance whole.
Go deeperChapter 13, The Case Against: the sunset protocol, and the tool-disappears test this step just passed.
This tutorial is one behavior, on one channel, under one contract, with the structural half supplied by a library. The full article is the system it belongs to. The map:
| Tutorial step | Article chapter | What the article adds |
|---|---|---|
| 1. Renderer | Ch 2, From Screens to Behaviors | Why the screen became an output |
| 2. Tokens | Ch 4, One Source of Truth | The three renderings; the prototype as eval surface |
| 3. Catalog | Ch 5, Semantic Components | Nouns, verbs, and where the axes meet |
| 4. Behavior | Ch 5, Ch 7 | The same behavior on chat, voice, and email |
| 5. Briefing | Ch 6, Brand as Governed Context | The full brand file and the never-say list |
| 6. Contract | Ch 7, Every Surface, One Promise | Placement and reachability across every channel |
| 7. Fallback | Ch 8, The Control Plane, Twice | The full runtime plane the fallback belongs to |
| 8. Evals | Ch 10, Evaluation-Driven Design | Floors and dials; the experiment lane |
| 9. Hooks | Ch 11, Ch 13 | The truth loop and the sunset protocol |
Read the full article for the argument, and Part I for the files this one assumed.