Generative UI lets a model assemble an interface from a user request, but production systems cannot safely accept arbitrary generated React, HTML, or JavaScript. A safer architecture asks the model to emit a structured UI specification against a predeclared catalog of components, actions, and data bindings. The frontend validates that specification and renders it through trusted components.
The json-render project is a concrete example of this pattern. Developers define a catalog with allowed components, Zod prop schemas, action names, optional slots, and descriptions that help the model choose the right building blocks. The model produces JSON, not executable code. A registry maps each allowed component type to a real implementation in React, Vue, Svelte, Solid, React Native, PDF, email, terminal, or 3D renderers.
The transferable lesson is the separation between generation and execution. The model gets creative control over composition, layout, copy, and selected actions, while engineers keep control over component implementations, prop validation, action dispatch, permissions, state mutation, and rendering targets. This is useful for dashboards, form builders, AI search results, customer portals, reports, adaptive workflows, and internal tools that need personalized structure without accepting untrusted code.
A catalog is the vocabulary the model may use. It lists component names, prop schemas, child slots, action names, and short descriptions. The schema defines the grammar of a valid spec; the catalog defines the domain-specific nouns and verbs. This keeps the model from inventing unsupported components such as MagicChart or passing undeclared props into real components.
The registry is the executable side of the system. It maps a catalog type such as Metric to a trusted renderer function. The model never ships implementation code for Metric; it only chooses that component and supplies validated props. This registry boundary is what turns generative UI from code generation into constrained composition.
Generated specs should not include arbitrary event handlers. Instead, components emit named actions with validated parameters. The host application decides whether an action is allowed, how it is authorized, and what side effects it can perform. This allows a generated dashboard button to request export_report without receiving direct access to file systems, network clients, or user credentials.
The model produces a typed data structure that describes a UI tree. Your application validates and interprets that structure through known components. The result is dynamic UI with a smaller attack surface than raw code generation.
Start with the smallest useful set of components and actions. Good catalogs are domain-specific: a sales dashboard might include Metric, TrendChart, AccountTable, and ExportButton, while a support workflow might include CaseSummary, ResolutionSteps, and EscalationForm. Each prop schema should be strict enough that bad output fails validation before rendering.
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
tone: z.enum(["neutral", "success", "warning"]).nullable(),
}),
slots: ["default"],
description: "Container for a focused dashboard section",
},
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
format: z.enum(["currency", "percent", "number"]),
}),
description: "Single business metric with a formatted value",
},
},
actions: {
export_report: {
params: z.object({ format: z.enum(["pdf", "csv"]) }),
description: "Export the current dashboard",
},
},
});
The registry binds catalog entries to real components. The renderer walks the spec tree, validates element types and props, resolves children, and calls the trusted component implementation. If the model emits an unknown component or invalid prop, the renderer rejects the spec or renders a controlled error state.
import { defineRegistry, Renderer } from "@json-render/react";
import { catalog } from "./catalog";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<section className={`card card-${props.tone ?? "neutral"}`}>
<h3>{props.title}</h3>
{children}
</section>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<strong>{formatValue(props.value, props.format)}</strong>
</div>
),
},
});
export function GeneratedDashboard({ spec }) {
return <Renderer spec={spec} registry={registry} />;
}
For interactive experiences, the model can stream a partial JSON spec instead of waiting for the whole tree. The frontend should render only complete, validated elements, preserve stable IDs, and avoid layout jumps when later chunks add children. Streaming makes the interface feel responsive, but it requires careful buffering and validation at element boundaries.
A user can ask for “a weekly revenue view with churn warnings and renewal tasks.” The model chooses dashboard components, groups metrics into cards, selects chart types from the catalog, and wires buttons to named actions. The application still controls the data sources and action implementations.
Search results often need a different shape depending on the answer: comparison tables, citation panels, map cards, timelines, file previews, or next-step forms. A schema-constrained UI spec lets the model select an appropriate presentation while the frontend keeps rendering, accessibility, and source-citation behavior consistent.
When the spec format is renderer-independent, the same logical UI can target web, mobile, email, PDF, terminal, or a server-rendered app. Each platform has its own registry, but all share the catalog vocabulary and validation model. This works best when component semantics are stable and platform-specific details stay inside the registry.
Generate the system prompt from the catalog so the model sees the exact component names, prop shapes, action names, and custom rules. Do not maintain a separate hand-written prompt that drifts from the renderer. The prompt should tell the model to use stable element IDs, prefer simple layouts, and produce only valid spec JSON.
const system = catalog.prompt({
customRules: [
"Use Card as the root for dashboard sections.",
"Do not invent component types or action names.",
"Prefer Metric for single numeric values and Table for row data.",
],
});
const spec = await generateObject({
model,
system,
prompt: userRequest,
schema: catalog.specSchema(),
});
Treat model output as untrusted input. Parse JSON, validate against the spec schema, verify element references, reject cycles, cap tree depth, check action permissions, and normalize defaults before rendering. A separate normalization pass also gives you one place to assign missing labels, remove unsupported optional fields, and collect warnings for review.
Action dispatch should run through a capability table. A generated spec can request refresh_data, but the host decides whether the current user, tenant, route, and component instance may perform that action. This pattern prevents a broad catalog from becoming a broad permission grant.
Generated specs may be stored, replayed, exported, or edited later. Include a catalog version in each spec and maintain migration functions for renamed components, changed props, and deprecated actions. Without versioning, a catalog cleanup can silently break saved experiences.
RevenueTrend rather than a pile of layout primitives when the domain has a clear semantic component.