Schema-Constrained Generative UI

Frontend Engineering advanced Building with AI ~35 min read

Overview

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.

Core Concepts

Catalog as Vocabulary

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.

Registry as Trusted Runtime

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.

Actions as Named Capabilities

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.

Key Concept: Generate Specs, Not Code

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.

How It Works

Define the Catalog

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",
    },
  },
});

Render Through a Registry

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} />;
}

Stream Structured Specs

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.

Practical Applications

Personalized Dashboards

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.

AI Search and RAG Results

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.

Cross-Platform Experience Generation

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.

Implementation Patterns

Prompt from the Catalog

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(),
});

Validate, Normalize, Then Render

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.

Use Capability-Scoped Actions

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.

Version Specs and Catalogs Together

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.

Performance Considerations

Common Pitfalls

Further Reading