Tutorial · Generative UI foundations II

From JSON intent to trusted React components.

Build five approved UI components, describe them with a Zod discriminated union, and prove that validated JSON can dynamically choose what React renders—without allowing arbitrary generated code.

ReactTypeScriptZodJSONComponent Registry

1. The core idea

This experiment uses seven small files plus App.tsx. The JSON never creates arbitrary React code. Instead, it declares a type; Zod validates that declaration; and a renderer maps the approved type to one of your trusted React components.

JSON
  │
  ▼
Zod validation
  │
  ▼
UIRenderer
  │
  ├── "text"   → <Text />
  ├── "card"   → <Card />
  ├── "alert"  → <Alert />
  ├── "table"  → <Table />
  └── "button" → <Button />
Key principle: JSON or a future LLM may describe intent, but the application owns the executable components.

2. Create the project folders

src/
├── components/
│   ├── Text.tsx
│   ├── Card.tsx
│   ├── Alert.tsx
│   ├── Table.tsx
│   └── Button.tsx
├── generative-ui/
│   └── UIRenderer.tsx
├── schemas/
│   └── uiSchema.ts
├── App.tsx
└── main.tsx

The source code for this Generative UI learning project is being maintained in the public repository vpereira3000/generative_ui. Readers can use the repository as the companion implementation while following the tutorial.

3. Create the Text component

Create src/components/Text.tsx:

type TextProps = {
  content: string;
};

export function Text({ content }: TextProps) {
  return <p>{content}</p>;
}

This simple component accepts:

{
  "content": "Hello from Generative UI"
}

4. Create the Card component

Create src/components/Card.tsx:

type CardProps = {
  title: string;
  description: string;
};

export function Card({
  title,
  description,
}: CardProps) {
  return (
    <div
      style={{
        border: "1px solid #ccc",
        borderRadius: "8px",
        padding: "16px",
        marginBottom: "12px",
      }}
    >
      <h2>{title}</h2>
      <p>{description}</p>
    </div>
  );
}
{
  "type": "card",
  "props": {
    "title": "Oracle Database",
    "description": "Enterprise relational database platform."
  }
}

5. Create the Alert component

Create src/components/Alert.tsx:

type AlertProps = {
  title: string;
  message: string;
};

export function Alert({
  title,
  message,
}: AlertProps) {
  return (
    <div
      style={{
        border: "1px solid orange",
        borderRadius: "8px",
        padding: "16px",
        marginBottom: "12px",
      }}
    >
      <strong>{title}</strong>
      <p>{message}</p>
    </div>
  );
}
{
  "type": "alert",
  "props": {
    "title": "Database Warning",
    "message": "CPU usage reached 95%."
  }
}

6. Create the Button component

Create src/components/Button.tsx:

type ButtonProps = {
  label: string;
};

export function Button({
  label,
}: ButtonProps) {
  return (
    <button
      onClick={() => alert(`Clicked: ${label}`)}
      style={{
        padding: "10px 16px",
        cursor: "pointer",
      }}
    >
      {label}
    </button>
  );
}
For this first experiment, JSON only controls the button label. It cannot inject arbitrary JavaScript. That distinction becomes critical once an LLM generates the UI description.

7. Create the Table component

Create src/components/Table.tsx:

type TableProps = {
  columns: string[];
  rows: string[][];
};

export function Table({
  columns,
  rows,
}: TableProps) {
  return (
    <table
      style={{
        borderCollapse: "collapse",
        width: "100%",
        marginBottom: "16px",
      }}
    >
      <thead>
        <tr>
          {columns.map((column) => (
            <th
              key={column}
              style={{
                border: "1px solid #ccc",
                padding: "8px",
              }}
            >
              {column}
            </th>
          ))}
        </tr>
      </thead>

      <tbody>
        {rows.map((row, rowIndex) => (
          <tr key={rowIndex}>
            {row.map((cell, cellIndex) => (
              <td
                key={cellIndex}
                style={{
                  border: "1px solid #ccc",
                  padding: "8px",
                }}
              >
                {cell}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Example data:

{
  "type": "table",
  "props": {
    "columns": ["Database", "Type"],
    "rows": [
      ["Oracle", "Commercial"],
      ["PostgreSQL", "Open Source"]
    ]
  }
}

8. Define the Zod UIComponent schema

Create src/schemas/uiSchema.ts:

import { z } from "zod";

const TextSchema = z.object({
  type: z.literal("text"),
  props: z.object({
    content: z.string(),
  }),
});

const CardSchema = z.object({
  type: z.literal("card"),
  props: z.object({
    title: z.string(),
    description: z.string(),
  }),
});

const AlertSchema = z.object({
  type: z.literal("alert"),
  props: z.object({
    title: z.string(),
    message: z.string(),
  }),
});

const ButtonSchema = z.object({
  type: z.literal("button"),
  props: z.object({
    label: z.string(),
  }),
});

const TableSchema = z.object({
  type: z.literal("table"),
  props: z.object({
    columns: z.array(z.string()),
    rows: z.array(
      z.array(z.string())
    ),
  }),
});

export const UIComponentSchema =
  z.discriminatedUnion("type", [
    TextSchema,
    CardSchema,
    AlertSchema,
    ButtonSchema,
    TableSchema,
  ]);

export type UIComponent =
  z.infer<typeof UIComponentSchema>;

This gives us runtime validation plus TypeScript type safety from one definition.

9. Understand the discriminator

z.discriminatedUnion("type", ...) tells Zod to inspect type and select the matching schema.

{ "type": "card" }
CardSchema
{ "type": "table" }
TableSchema

10. Create the dynamic renderer

Create src/generative-ui/UIRenderer.tsx:

import type { UIComponent } from "../schemas/uiSchema";

import { Text } from "../components/Text";
import { Card } from "../components/Card";
import { Alert } from "../components/Alert";
import { Button } from "../components/Button";
import { Table } from "../components/Table";

type UIRendererProps = {
  component: UIComponent;
};

export function UIRenderer({
  component,
}: UIRendererProps) {
  switch (component.type) {
    case "text":
      return <Text {...component.props} />;

    case "card":
      return <Card {...component.props} />;

    case "alert":
      return <Alert {...component.props} />;

    case "button":
      return <Button {...component.props} />;

    case "table":
      return <Table {...component.props} />;

    default:
      return null;
  }
}

This switch is effectively our first component registry.

11. Test the renderer with JSON

Replace src/App.tsx with:

import {
  UIComponentSchema,
} from "./schemas/uiSchema";

import { UIRenderer } from "./generative-ui/UIRenderer";

function App() {
  const jsonData = {
    type: "card",
    props: {
      title: "Oracle Database 26ai",
      description:
        "A database platform with integrated AI and vector capabilities.",
    },
  };

  const result =
    UIComponentSchema.safeParse(jsonData);

  if (!result.success) {
    return (
      <div>
        <h1>Invalid UI definition</h1>
        <pre>
          {JSON.stringify(
            result.error,
            null,
            2
          )}
        </pre>
      </div>
    );
  }

  return (
    <div
      style={{
        maxWidth: "800px",
        margin: "40px auto",
        fontFamily: "Arial",
      }}
    >
      <h1>Generative UI Lab</h1>

      <UIRenderer
        component={result.data}
      />
    </div>
  );
}

export default App;

Start the application:

npm run dev

Open http://localhost:5173. The JSON contains type: "card", so the renderer selects <Card />.

12. Prove that data selects the interface

Do not modify the component files, renderer, or schema. Change only jsonData:

const jsonData = {
  type: "alert",
  props: {
    title: "Database Warning",
    message: "CPU usage has reached 95%.",
  },
};

Vite hot reloads. React now renders an Alert instead of a Card.

You changed data, not React code. Yet a different UI component appeared. This is the central proof of the experiment.

13. Test Table, Button and Text

Table

const jsonData = {
  type: "table",
  props: {
    columns: [
      "Database",
      "License",
      "Vector Support",
    ],
    rows: [
      ["Oracle", "Commercial", "Yes"],
      ["PostgreSQL", "Open Source", "Yes"],
      ["MySQL", "Open Source", "Yes"],
    ],
  },
};

Button

const jsonData = {
  type: "button",
  props: {
    label: "Analyze Database",
  },
};

Text

const jsonData = {
  type: "text",
  props: {
    content:
      "Welcome to our first Generative UI experiment.",
  },
};

14. Prove that Zod protects the application

Now intentionally send invalid props:

const jsonData = {
  type: "card",
  props: {
    title: 12345,
    description: true,
  },
};

The schema expects strings, so UIComponentSchema.safeParse(jsonData) fails and your application displays Invalid UI definition instead of attempting to render bad data.

15. Reject an unauthorized component

const jsonData = {
  type: "videoPlayer",
  props: {
    url: "something.mp4",
  },
};

Because videoPlayer does not exist in the discriminated union, Zod rejects the object.

Security property: a future model cannot invent capabilities such as Terminal, FileDelete, ExecuteShell, DatabaseDrop, or RunJavaScript unless you explicitly expose and validate them.

16. What we built

                    JSON
                     │
                     ▼
             UIComponentSchema
                   Zod
                     │
               valid / invalid
                     │
                     ▼
                UIRenderer
                     │
           inspect component.type
                     │
          ┌──────────┼──────────┐
          ▼          ▼          ▼
       "card"     "table"    "alert"
          │          │          │
          ▼          ▼          ▼
       <Card />   <Table />   <Alert />
                     │
                     ▼
                  Browser
textText
cardCard
alertAlert
tableTable
buttonButton

17. Why this is already Generative UI

Today, you manually write:

{
  "type": "table",
  "props": { ... }
}

Later, an LLM will generate the same declarative UI description. The frontend architecture does not need to change.

User
  │
  ▼
 LLM
  │
  ▼
Structured UI JSON
  │
  ▼
 Zod
  │
  ▼
UIRenderer
  │
  ▼
React component

The LLM does not generate React. It generates a declarative description of the interface. Your application owns the real components.

18. Follow the implementation on GitHub

This tutorial is part of a public, incremental Generative UI learning project. Readers can inspect the repository, clone it, or download the current source as a ZIP:

Open vpereira3000/generative_ui on GitHub →

Download the main branch as ZIP →

git clone https://github.com/vpereira3000/generative_ui.git
cd generative_ui

As each tutorial is implemented and committed, GitHub becomes the direct inspection point for the exact source files discussed in the article.

19. Final proof

Same React application
        +
Same renderer
        +
Same trusted components
        ↓
Different validated JSON
        ↓
Different interface

You have implemented the basic mechanism behind Generative UI: declarative intent, runtime validation, a trusted component vocabulary, and dynamic rendering.

Next experiment: allow the JSON to contain an array of components so one response can compose an entire interface instead of selecting only one component.