Generative UI with Just JSX
Generative UI that lets models write JSX, validates it in Cloudflare Dynamic Workers, and ships JSON — safer than patches, lighter than custom DSLs.
Chat works until the answer should be a UI. A status board as markdown. Product picks as a bullet list. A filterable grid… still paragraphs.
Generative UI means the model builds that interface instead of describing it. The hard part is doing it safely: your components only, props checked, nothing untrusted running in the browser, and a way to recover when the model gets props wrong.
jsx2ui is one cut at that. The model writes React-style JSX against a catalog you control. A Cloudflare Dynamic Worker runs and validates it. The client only ever sees JSON.
Happy path
JSX in → sandbox → Zod → UINode JSON → your React catalog.
Happy path
Valid JSX → sandbox → JSON → your components
0/4·Model → JSX
- 1
- 2
- 3
- 4
- 1Model → JSX
- 2Dynamic Worker
- 3Validate → JSON
- 4React catalog
Model is about to write JSX…
Client receives JSON only — never model code.
Why JSX
Models already know React. You mostly send the component contract — not a
tutorial on how to speak your format. .map(), ternaries, and template strings
beat hand-enumerating every node in a JSON tree. And the grammar is already in
the weights, which shows up as fewer malformed trees than with a home-grown DSL.
vs JSON / JSONC patches
json-render-style systems have the model emit structured patches; the client compiles a spec. Patches are a fine wire format. They’re a worse generation format when the alternative is JSX.
| jsx2ui | json-render | |
|---|---|---|
| Model writes | JSX it already knows | Ops you have to teach |
| Work happens | Server sandbox → JSON tree | Client compiles the stream |
| System prompt | ~¼ the size (component interfaces) | Catalog + patch grammar |
| Time to UI | ~½ in informal benchmarks | Baseline |
| Feature set | Experiment — not full parity | Matureer surface area |
Those numbers are from informal side-by-side runs on similar catalogs. Less prompt and faster generation; not feature parity.
vs custom syntax
OpenUI-style mini-languages need longer prompts and few-shots for a grammar the model never pretrained on. jsx2ui doesn’t invent a language — closed catalog + Zod schemas, emitted as TS-looking interfaces.
Fail closed, then retry
Bad props don’t get silently “fixed.” Validation rejects the tree, returns a
structured error, and the model calls createUI again.
Repair loop
Bad props fail closed → model retries → valid JSON renders
0/4·Attempt 1
- 1
- 2
- 3
- 4
- 1Attempt 1
- 2Reject
- 3Attempt 2
- 4Render
Model writing JSX…
Waiting for a valid UINode tree…
{ rendered: false, error: string, regenerate_full: true }
// → model retries (default budget: 4)
// → { rendered: true, ui: UINode }
The Cloudflare bit
Dynamic Workers (worker_loaders / LOADER) are the sandbox: fresh isolate,
globalOutbound: null, no app bindings, imports stripped, only whitelisted
component names.
JSX → transpile → rewrite operators → Dynamic Worker → whitelist + Zod → UINode JSON → React
Five layers: sandbox, component whitelist, Zod props, immutable state refs, import isolation. LLM output is untrusted input; only validated JSON leaves.
Custom JSX runtime, not React
The sandbox doesn’t run React. Sucrase points JSX at a virtual
gen-ui/jsx-runtime that builds plain { type, props, children } trees.
Component names are string constants (Card = "Card"). What leaves is data —
never elements, never handlers.
State stays yours
Static trees are the easy case. The useful case is a search box, a filter chip,
a list that shrinks as you type — without the model inventing onClick
handlers or touching your real store.
You pass the same JSON-shaped state into createGenUI on the server and
useGenUI on the client. The prompt tells the model an s variable is already
there; the model just writes against it.
<>
<Input label="Search" value={bind(s.query)} />
<Text>{`Showing results for "${s.query}"`}</Text>
{s.query && <Badge>Searching</Badge>}
{s.items
.filter((item) => s.filter === 'all' || item.status === s.filter)
.map((item) => (
<Row title={item.title} />
))}
</>
Looks like normal React. Under the hood the sandbox turns reads and bind(...)
into JSON paths ($ref, $bind, $tmpl, $cond, $filter) — not live values
or functions. The client resolves those paths against state you own, so
conditionals and filters stay reactive as the user types.
For inputs, value={bind(s.query)} is enough: the client wires a controlled
value plus onChange. You can also be explicit with
onChange={bind(s.query)} if you prefer. Either way the model never ships a
handler — only a path.
State is read-only in generated code (s.query = "x" throws). Forms go through
bind(). Keep the exposed shape small; field names land in the system prompt.
Reactivity stays in your app. The model only describes structure and which paths matter.
Streaming previews
The model still writes full JSX — patches are a wire format for the client, not the generation language.
- Pass the AI SDK
writerintogenUI.tools(writer)(fromcreateUIMessageStream) - As tool input streams, partial JSX is auto-closed, run through the sandbox, and diffed against the last good tree
- Server emits transient
data-gen-uiparts:{ streaming: true, patches, toolCallId } - Client
onDataapplies those JSON patches into a live preview tree - Final
executestill validates fail-closed; no writer → final result only
partial JSX → autocomplete → sandbox → UINode → diff → patches → applyPatches → preview
Wiring it up
// schema (Worker)
import { defineComponents, z } from 'jsx2ui';
import { createGenUI } from 'jsx2ui/ai-sdk';
export const components = defineComponents([
{
name: 'Metric',
props: z.object({
label: z.string(),
value: z.string(),
tone: z.enum(['green', 'orange', 'default']).optional(),
}),
},
// ...
]);
const state = { query: '', filter: 'all', items: [] };
const genUI = createGenUI({ loader: env.LOADER, components, state });
// createUIMessageStream({ execute({ writer }) { ... } })
streamText({
system: genUI.systemPrompt(),
tools: genUI.tools(writer), // writer → streamed data-gen-ui patches
stopWhen: genUI.stopCondition(),
// ...
});
// catalog + client (React)
import { createCatalog } from 'jsx2ui/react';
import { useGenUI } from 'jsx2ui/react/ai-sdk';
export const catalog = createCatalog({
Title,
Card,
Metric,
Badge,
Input,
Row,
});
const { parsePart, onData } = useGenUI({
catalog,
state: { query: '', filter: 'all', items: [] },
});
// useChat({ onData }) → patches update preview
// message parts → parsePart(part) → final / loading UI
// wrangler
{ "worker_loaders": [{ "binding": "LOADER" }] }
Same state shape on both sides. The server teaches the model the paths; the
client owns the values and re-renders when they change.
I’m using this when chat needs to become UI: model writes JSX, Dynamic Worker validates (and streams preview patches), errors kick a retry, client renders your components from JSON — including inputs and filters bound to state you own. Early, and deliberately not feature-complete — but enough that I’m shipping with it.