Declarative UI for models
Generative UI is safest when a model emits structured data, not markup. Your app renders it with a fixed allow-list of trusted components — never raw HTML.
In one line: the safest way to let a model build UI is to have it emit a structured description — pick a component, supply the data — and let your app do the rendering from a fixed roster of trusted components, never raw HTML.
What it is
Generative UI is when a model decides what interface to show, not just what text to say. There are two ways to wire that up, and only one of them is safe.
The tempting version: ask the model to emit HTML (or JSX), then drop it into the page. This works in a demo and detonates in production — you have just handed an untrusted text generator a direct line to your DOM.
The declarative version: the model emits structured data — a small JSON tree where each node names a component from a roster you control and supplies its props. { "type": "button", "props": { "text": "Sign up" } }, not <button>Sign up</button>. Your app reads that data and renders the matching trusted component. The model chooses from your menu and fills in the blanks; it never writes markup.
Why it matters
- Safety. Data can't carry a
<script>tag or anonerrorhandler into your page. The model's output is values, and values get escaped like any other untrusted string. There is no injection surface because there is no markup channel. - Consistency. Every button the model "creates" is your button — your spacing, your focus ring, your accessible label. The generated UI inherits your design system for free, because it is literally made of your components.
- The model does less. It supplies data and choices, not pixels. That is a far smaller, far more reliable job — and one a typed schema can validate before anything renders.
See it
Tweak it3
Switch between the specs in the rail and watch the same renderer turn each data tree into trusted components. The JSON underneath each render is exactly what a model would emit — notice that nowhere in it is there any HTML.
How it works
Four pieces turn "model output" into "safe UI":
- An allow-list of components. You decide the roster up front — heading, text, input, button, badge, listitem. Anything outside it cannot be rendered, full stop.
- A typed schema. A Zod schema describes the legal shape of each node: which
typevalues exist, and which props each one requires. The model's raw output is parsed through it, so malformed or unexpected nodes are rejected before they reach a component. - A renderer switch. A single
render(node)function maps each validatedtypeto its real component. Thedefaultbranch is a visible fallback — never a passthrough — so an unknown type degrades to a labelled placeholder instead of vanishing or, worse, being injected. - Streaming / partial handling. Models stream, so the data tree arrives incrementally. Parse defensively: render the nodes you have, skip nodes that haven't validated yet, and re-render as more arrives. A node that never validates simply never appears.
Build it
A safe renderer is small. A discriminated-union type names the legal nodes, a switch maps each to a real component, and the default branch catches anything that slipped past validation.
type UiNode =
| { type: "heading"; props: { text: string } }
| { type: "text"; props: { text: string } }
| { type: "button"; props: { text: string } }
| { type: "input"; props: { id: string; label: string } };
// Each branch returns a component WE wrote — never markup from the spec.
function render(node: UiNode, key: number) {
switch (node.type) {
case "heading":
return <h3 key={key} className="text-lg font-semibold">{node.props.text}</h3>;
case "text":
return <p key={key} className="text-sm">{node.props.text}</p>;
case "button":
return <button key={key} type="button" className="btn">{node.props.text}</button>;
case "input":
return (
<label key={key} className="field">
<span>{node.props.label}</span>
<input id={node.props.id} type="text" />
</label>
);
default:
// Unknown type — show a visible fallback, never inject it.
return <p key={key} className="fallback">Unknown component: {(node as { type: string }).type}</p>;
}
}
export function SpecView({ spec }: { spec: UiNode[] }) {
return <div>{spec.map((node, i) => render(node, i))}</div>;
}Make it yours
Use the controls beside the demo above to change ui spec, inject an unknown component, and show the raw spec json — each change updates the example live.
Experiment in the playground- Add a node type to the allow-list — say a
divideror anavatar— and wire it into the renderer switch. Notice the model can use it only once you have built and trusted the component. - Feed the renderer a node with an unknown
typeand confirm it lands in the fallback branch instead of breaking the render. - Sketch the Zod schema for one node: which props are required, which are optional, and what happens to output that doesn't match.
Reproduce it with an LLM
Reproduce it with an LLM
You are a senior engineer building generative UI. Design a small, safe JSON schema that a language model can emit to describe a UI, which your app renders with a fixed set of trusted, pre-built components — NOT arbitrary HTML. Support a handful of component types (heading, text, card, button, input, list) with typed props, and a validation step (e.g. Zod) that rejects unknown types or props before rendering. Crucially: the model only chooses from your component allow-list and supplies data — it never emits markup, scripts, or styles — so untrusted output can't inject anything. Show the schema, the Zod validator, and a renderer switch that maps each type to a real component (with a fallback for unknown types). Note how to handle streaming/partial objects. Return only the code.
Pitfalls & accessibility
- Validate props too, not just
type. A node with the righttypebut a missing or wrong-typed prop should be rejected by the schema, not crash the renderer mid-stream.