Skip to content
Generative & agentic UILesson 1 of 4Intermediate16 min

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.

Kept on this device only.

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 an onerror handler 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

Live demo
Tweak it3
Switch the UI spec; the app renders trusted components from the model's structured output.

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":

  1. 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.
  2. A typed schema. A Zod schema describes the legal shape of each node: which type values 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.
  3. A renderer switch. A single render(node) function maps each validated type to its real component. The default branch is a visible fallback — never a passthrough — so an unknown type degrades to a labelled placeholder instead of vanishing or, worse, being injected.
  4. 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.

tsx
A safe, allow-list renderer for model-emitted UI
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 divider or an avatar — 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 type and 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 right type but a missing or wrong-typed prop should be rejected by the schema, not crash the renderer mid-stream.

Further reading