Practical guide
Generative UI with json-render
A json-render tutorial. Let a model build pages from your own React components, in a language where a wrong colour cannot be said.

What you will build
Ask a model for a pricing section and you get one that is almost right. The heading is one step off your type scale. The background is nearly, but not exactly, your surface token. There is a gradient nobody asked for.
The usual fix is a stricter prompt. It does not hold, and the reason is simple: you are asking the model not to use words you gave it. If it can write a class attribute, it can write any class.
json-render takes the words away instead. You describe your own components once, with typed props, and the model composes pages inside that list. A wrong colour is not refused. It cannot be said. Pages come back as JSON, drawn by the components you already shipped, in light and dark, responsive, because none of those choices belong to the model.
Everything is taught from one example you can clone and run: the free Foundry template, rebuilt so all eight pages render from specs, with the hand-written originals kept beside them so you can compare the two in a browser. Thirteen pictures in the guide are screenshots of it.
You will cover defineCatalog and defineRegistry, binding content with $state and repeat, streaming a model's answer with catalog.prompt(), the test that proves a generated page matches the hand-written one byte for byte, and exporting a page back to ordinary React. Plus the real cost: about 100 kB of JavaScript, and 60 of 64 screenshots pixel-identical.
- Describe your components to a model with
defineCatalog, so a wrong value is an error, not a surprise. - Point each name at your real React component with
defineRegistry. - Turn a page into a JSON file, and render it.
- Move your words into data with
$stateandrepeat, so a model can write them. - Prove a generated page matches your hand-written one, byte for byte, in CI.
- Export a page back to normal React, so nobody inherits a runtime.
Who this is for
React developers trying json-render, and anyone who wants a model to build pages from a design system they already have.
The guide
Ask a model for a section and you get one that is almost right. The usual fix is a stricter prompt, and it does not hold. Here is the fix that does, with the code, from a template you can clone.
The problem, in one paragraph
You ask for a pricing section. You get one. It is almost right. The heading is text-3xl where your scale says text-2xl. The card background is #f9fafb, nearly your surface token but not quite. There is a gradient nobody asked for.
One section, you fix by hand. Forty sections, and you no longer have a design system. You have a design system plus a thousand small betrayals of it.
So you write a stricter prompt. It does not work, and the reason is exact.
needs attention
You are asking the model not to use words you gave it. If it can write a class attribute, it can write any class. Every rule in your prompt is a request, not a wall.
The fix: take the words away
Stop trying to constrain the output. Change the language instead.
You describe your own components once, with typed props. The model composes pages inside that list and nothing else. A wrong colour is not refused, it cannot be typed. This is what json-render does, and it is an open-source project from Vercel Labs.
| Stricter prompt | A typed catalog | |
|---|---|---|
| Wrong colour | Possible, caught in review | Cannot be expressed |
| Invented component | Possible | Fails validation |
| Breakpoints | The model decides | Your component decides |
| Dark mode | Hope | Free, it is your tokens |
| Review burden | Every section, forever | The catalog, once |
How big is one piece?
This is the choice that decides everything else. How large is one thing the model is allowed to make?
| Size | One unit is | Verdict |
|---|---|---|
| Page | A whole route | A picker, not a generator |
| Section | Hero, pricing, FAQ | Start here. One afternoon |
| Element | A row, a cell, and its words | Then here. Where content becomes editable |
| DOM node | A div with classes | Never |
needs attention
The last row is where most demos land. The moment a class name is data the model writes, you have handed back every raw value your token file exists to forbid. It looks like it works. It drifts within a week.
Step 1: write the catalog
A catalog is a list of what may be said. Plain TypeScript, Zod schemas.
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Hero: {
props: z.object({ headingLevel: z.enum(["1", "2"]) }),
description: "Oversized headline, lede, two calls to action and a facts strip.",
},
ServicesLedger: {
props: z.object({ heading: z.enum(["section", "page"]) }),
description: "Six services as a ruled table of contents with a from-price.",
},
},
});Three things there are doing the real work.
Every prop is a closed set. z.enum(["section", "page"]) means the model picks one of two things you designed. A third is an error caught before anything renders. Whenever you reach for a plain string, ask whether a list of choices would do. It usually would.
The description is the prompt. You never write a long system prompt. One sentence per component saying what job it does, and catalog.prompt() builds the rest. Write them for someone who cannot see the page: "six services as a ruled table of contents with a from-price" is useful; "hairline rows with a coral accent" is not, because the model is choosing by purpose.
There is no class, colour or size anywhere. That absence is the feature.
Step 2: point the names at real components
The registry maps each catalog name to the React you already shipped. At section level, one line each.
import { defineRegistry } from "@json-render/react";
import { Hero } from "../components/hero";
import { ServicesLedger } from "../components/services-ledger";
export const { registry } = defineRegistry(catalog, {
components: {
Hero: ({ props }) => <Hero headingLevel={props.headingLevel === "1" ? 1 : 2} />,
ServicesLedger: ({ props }) => <ServicesLedger heading={props.heading} />,
},
});A registry component receives { props, children, slots }. This is the one file where class strings belong, because it is code you wrote and can lint.
Step 3: a page becomes a file
{
"root": "page-1",
"elements": {
"page-1": { "type": "Page", "props": {},
"children": ["services-ledger-1", "faq-1", "cta-1"] },
"services-ledger-1": { "type": "ServicesLedger",
"props": { "heading": "page" }, "children": [] },
"faq-1": { "type": "Faq",
"props": { "variant": "engagement" }, "children": [] }
}
}Flat, with children referenced by id. Odd at first, then you stop noticing, and you get two things for it: a model can stream it as patches, and any element can be addressed without walking a tree.
<StateProvider initialState={state}>
<VisibilityProvider><ActionProvider>
<Renderer spec={spec} registry={registry} />
</ActionProvider></VisibilityProvider>
</StateProvider>All three providers are required, even with no state, no visibility rules and no actions. The renderer's hooks throw when a provider is missing, and the error does not tell you which one. That is an hour you can skip.
A complete component, side by side
This is the whole of one real section from the worked example: a band of client names. First, what the model writes.
{
"band": { "type": "ClientBandShell",
"props": { "note": { "$state": "/clientBand/note" } },
"children": ["list"] },
"list": { "type": "ClientMarkList", "props": {}, "children": ["mark"],
"repeat": { "statePath": "/clientBand/clients" } },
"mark": { "type": "ClientMark",
"props": { "name": { "$item": "" } }, "children": [] }
}Three elements. $state reads a path from your data. repeat draws the children once per item. $item is the current item.
Now the React those names point at, unchanged from the shipped theme:
const clientMark = definePart({
catalog: {
props: z.object({ name: z.string().max(80) }),
description: "One client wordmark in the client band's list.",
},
render: ({ props }) => (
<li className="font-display type-d1 text-secondary-foreground">{props.name}</li>
),
});
const clientMarkList = definePart({
catalog: {
props: z.object({}),
description: "The client band's four-column list of wordmarks.",
},
render: ({ children }) => (
<ul className="grid grow grid-cols-2 gap-x-8 gap-y-5 sm:grid-cols-3 lg:grid-cols-4">
{children}
</ul>
),
});And the data, exported from the section file so the component and the model read the same words:
export const CLIENT_BAND = {
note: "Thirty-one companies since 2016, eight of them twice",
clients,
};That is the entire pattern. The JSON holds content and order. The React holds every class. Neither can reach into the other.
A repeating element draws its children once per item with nothing wrapped around them. So the repeating element is the <ul>, and its single child is the <li>. Getting that backwards is the most common first mistake.
Step 4: prove nothing changed
Here is the claim to be suspicious of: a page built from JSON renders the same as the one you wrote by hand. Not similar. The same. You can check it in about twenty lines.
const route = normalise(renderToString(createElement(HandWrittenRoute)));
const spec = normalise(renderToString(createElement(SpecPage, { spec: SPECS.home })));
if (route !== spec) fail(firstDifference(route, spec));Over all eight pages of the worked example, that prints:
ok home — 40627 bytes identical
ok work — 35690 bytes identical
ok services — 40293 bytes identical
ok team — 28759 bytes identical
ok contact — 35377 bytes identical
ok journal — 25188 bytes identical
ok legal — 19723 bytes identical
ok components — 173976 bytes identical
ok parity — 8/8 routesTwo traps in this test, both real
Use renderToString, not renderToStaticMarkup. The static renderer drops React's text separator comments. They paint nothing, but they split one text node in two, and a browser shapes text differently across that boundary. In the worked example that difference was sixteen pixels, and the byte comparison could not see it.
Normalise generated ids positionally. React's useId differs between the two trees. Map each id to a counter in document order rather than deleting them, so an accordion pointing at the wrong panel still fails.
Step 5: let the model write
Everything so far is deterministic. This part is small, because by now the interesting work is done.
import { streamText } from "ai";
import { catalog } from "@/render/catalog";
export async function POST(req: Request) {
const { prompt } = await req.json();
return streamText({
model: "anthropic/claude-haiku-4.5",
system: catalog.prompt({ customRules: ["Every route ends with the closing panel."] }),
prompt,
}).toTextStreamResponse();
}Put whatever the visitor typed into state, never into your instructions. Then validate, and repair rather than throw: drop an unknown type, clamp a list that ran long, move the closing panel back to the end.
The worst a fully hijacked model can do here is produce a different valid arrangement of your own sections. It cannot emit a script, a link, a class or a colour, because none of those are in the vocabulary. That is the security argument for the whole approach.
Step 6: hand it back as React
A JSON page is a fine runtime and a poor deliverable. Walk the spec and emit the file you would have written:
export default function ServicesPage() {
return (
<div className="flex min-h-screen flex-col bg-background …">
<SiteHeader />
<main id="main" className="flex-1">
<ServicesLedger heading="page" />
<Capabilities />
<Faq variant="engagement" />
<Cta />
</main>
<SiteFooter />
</div>
);
}In the worked example this round trip is byte-identical to the checked-in route on seven of eight pages, and a test holds it there. That test does more than it looks: if codegen can reproduce your routes exactly, your catalog lost nothing on the way in.
What it costs
| Route kind | First Load JS | Rendering |
|---|---|---|
| Hand-written React | 107–115 kB | Static |
| Rendered from a spec | 214 kB | Static |
About 100 kB, because the renderer is a client component and the sections come with it. Pages still prerender.
Whether to pay it is simple. If the page ships from source, run codegen and pay nothing. If a person edits it or a model assembles it live, pay it.
The visual cost was measured too: eight routes, four widths, both modes, sixty-four screenshot pairs against the hand-written original. Sixty were pixel-identical. The four that were not are one and eight pixels of text antialiasing.
Your first week
- Catalog five sections. The five you use most. Props as enums only. One sentence each.
- Registry and one spec. One line per section, then transcribe a real page into JSON by hand. This is the step that teaches you the format.
- The parity test. Do not skip it. It is the whole warranty.
- Make one section's content data. Pick a repeating one: a pricing table, a feature list.
- Let a model compose.
catalog.prompt(), a streaming route, validate and repair. - Codegen. Spec back to a route file, with a test that the round trip reproduces your existing routes.
- Screenshots. Compare generated against hand-written at four widths in both modes. Settle lazy images first, or you will measure your screenshotter.
Three things to decide early
What stays out. Some components should never be generated. In the worked example the site header, the footer and a validated enquiry form are placed whole and never decomposed, because they are behaviour, not content. A form with five fields, pending states and a discard dialog is not a thing a model should assemble.
Who owns the words. Composition is cheap to generate and easy to check. Content is where the value and the risk both are. Use your best model for the words and a cheap one for the structure.
What your test compares. A gate that compares a generated page against your own copy of a component proves only that the two halves agree. If the component drifts, both move together and the test stays green. Compare against the source of truth. That exact hole cost a real defect in the worked example, caught by screenshots rather than by the test meant to catch it.
What this is built on
- json-render on GitHub, the library this guide teaches
github.com/vercel-labs/json-render
- json-render documentation
json-render.dev/docs
- foundry-json-render, the complete worked example
github.com/tailthemes/foundry-json-render
- Foundry, the free template underneath it
tailthemes.com/themes/foundry
Inside the guide
- 01Why a stricter prompt will not hold your design system
- 02Three sizes of building block, and the one to avoid
- 03
defineCatalog: your components, written down - 04
defineRegistry: pointing the names at real code - 05A page as a JSON file, and what it needs to render
- 06The test that proves nothing changed
- 07
$stateandrepeat: turning words into data - 08Letting the model write, and checking what it sends back
- 09Back to React, and what it all costs