Skip to main content
Generative UI lets the AI generate complete user interfaces from natural language prompts. Instead of rendering text responses in chat bubbles, the AI output is the UI: forms, cards, dashboards, and more. The developer defines which components are available (the “catalog”), and the AI composes them into a valid UI tree. This pattern uses json-render, the Generative UI framework, to define component catalogs, generate specs with AI, and render them safely across React, Vue, Svelte, and Angular.

How it works

  1. Define a catalog: declare what components the AI can use, with typed props
  2. Prompt the AI: describe the UI you want in natural language
  3. AI generates a spec: a JSON document describing the component tree
  4. Render safely: json-render’s Renderer renders the spec using your components
The catalog acts as a guardrail: the AI can only use components you’ve defined, with props that match your schema. The output is always predictable and safe.

Define a component catalog

The catalog describes every component the AI is allowed to use. Each component has a Zod schema for its props and a description that the AI reads to understand when to use it:
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";

const catalog = defineCatalog(schema, {
  components: {
    Card: {
      description: "A card container with optional title and padding",
      props: z.object({
        title: z.string().optional(),
        padding: z.enum(["sm", "md", "lg"]).optional(),
      }),
    },
    TextInput: {
      description: "A text input field with optional label and placeholder",
      props: z.object({
        label: z.string().optional(),
        placeholder: z.string().optional(),
        type: z.enum(["text", "email", "password", "number", "textarea"]).optional(),
      }),
    },
    Button: {
      description: "A clickable button with label and style variants",
      props: z.object({
        label: z.string(),
        variant: z.enum(["primary", "secondary", "ghost", "link"]).optional(),
        fullWidth: z.boolean().optional(),
      }),
    },
  },
  actions: {},
});
Keep catalogs focused. Include only components the AI needs for the use case. A smaller catalog produces better results than a kitchen-sink approach.

Build a component registry

The registry maps each catalog component to its actual rendering implementation. Use defineRegistry to get type-safe bindings between the catalog props and your component functions:
import { defineRegistry, Renderer, JSONUIProvider } from "@json-render/react";

const { registry } = defineRegistry(catalog, {
  components: {
    Card: ({ props, children }) => (
      <div className="card">
        {props.title && <h2>{props.title}</h2>}
        {children}
      </div>
    ),
    TextInput: ({ props }) => (
      <div>
        {props.label && <label>{props.label}</label>}
        <input type={props.type ?? "text"} placeholder={props.placeholder} />
      </div>
    ),
    Button: ({ props }) => (
      <button className={props.variant ?? "primary"}>
        {props.label}
      </button>
    ),
  },
});

Connect to the agent

The agent uses structured output to return a json-render spec. Set up useStream with your agent’s assistant ID, then extract the spec from the AI message’s tool_calls:
import { useStream } from "@langchain/react";
import { AIMessage } from "@langchain/core/messages";

function GenerativeUI() {
  const stream = useStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "generative_ui",
  });

  const aiMessage = stream.messages.find(AIMessage.isInstance);
  const rawSpec = aiMessage?.tool_calls?.[0]?.args;

  // ... filter and render (see streaming section below)
}

Stream and render progressively

During streaming, the spec is built up incrementally. Elements arrive one at a time and may initially lack type or props. Filter to only complete elements and pass loading={true} to the Renderer, which tells it to silently skip children that haven’t arrived yet. The UI builds up component by component:
/*
 * Filter the streamed spec to only include elements with valid type/props,
 * enabling progressive rendering as the AI response builds up. Passing
 * loading={true} to the Renderer tells it to skip missing children silently.
 */
const spec = (() => {
  if (!rawSpec?.root || !rawSpec?.elements) return null;
  const rootEl = rawSpec.elements[rawSpec.root];
  if (!rootEl?.type || rootEl?.props == null) return null;

  const safeElements = {};
  for (const [key, el] of Object.entries(rawSpec.elements)) {
    if (el?.type && el?.props != null) {
      safeElements[key] = el;
    }
  }
  return { root: rawSpec.root, elements: safeElements };
})();

return (
  <>
    {spec && (
      <JSONUIProvider registry={registry}>
        <Renderer spec={spec} registry={registry} loading={stream.isLoading} />
      </JSONUIProvider>
    )}
  </>
);
The JSONUIProvider is required to set up json-render’s internal context providers (state, visibility, validation, actions). The Renderer component must be rendered inside it.

The spec format

The AI agent generates a flat JSON spec with a root key pointing to the root element and an elements map containing all components:
{
  "root": "login-card",
  "elements": {
    "login-card": {
      "type": "Card",
      "props": { "title": "Login" },
      "children": ["login-stack"]
    },
    "login-stack": {
      "type": "Stack",
      "props": { "direction": "vertical", "gap": "md" },
      "children": ["email-input", "password-input", "submit-btn"]
    },
    "email-input": {
      "type": "TextInput",
      "props": { "label": "Email", "placeholder": "Enter your email", "type": "email" },
      "children": []
    },
    "password-input": {
      "type": "TextInput",
      "props": { "label": "Password", "placeholder": "Enter your password", "type": "password" },
      "children": []
    },
    "submit-btn": {
      "type": "Button",
      "props": { "label": "Sign In", "variant": "primary", "fullWidth": true },
      "children": []
    }
  }
}
Each element references its children by ID, and leaf elements like TextInput and Button have empty children arrays.

Best practices

  • Use descriptive component descriptions: the AI uses these to understand when to use each component. Clear descriptions lead to better UI generation.
  • Validate before rendering: always check that elements have valid type and non-null props before passing to the Renderer, since streaming delivers partial data.
  • Design for streaming: pass loading={true} during streaming so the Renderer gracefully handles children that haven’t arrived yet. Users see the UI build up in real time rather than waiting for the full response.
  • Style with design tokens: use CSS custom properties so rendered components adapt to light and dark themes automatically.
  • Wrap with JSONUIProvider: the Renderer must be inside a JSONUIProvider to access json-render’s internal context for state, visibility, and actions.