Tutorial

Generative UI in React: A Practical Guide

Learn how to implement AI-generated UI components in your React applications with real-world patterns.

A
Alex12 min read

Most GenUI prototypes fail at these five patterns

Generative UI demos look magical. Production GenUI apps break in five predictable ways: brittle tool selection, race conditions during streaming, runtime prop mismatches, no fallback when the model is down, and unbounded inference cost. This guide walks through the five patterns that actually keep a GenUI feature alive past the demo stage — registry, separation, skeletons, error boundaries, and state — plus the trade-offs each pattern hides, and concrete guidance for the two audiences who usually decide whether to ship: the engineering manager picking a stack, and the indie hacker deploying a side project on a tight budget.

Why React for Generative UI?

React's component model is naturally suited for Generative UI. Components are composable, typed, and can be rendered on the server or client. When an AI model "generates UI," what it actually does is select and compose React components with specific props.

This guide covers the patterns that work in production and the mistakes I see teams make when they first start building generative interfaces. I am assuming you have a working Next.js setup and have read the Vercel AI SDK basics — this is the practical layer on top of that foundation.

Pattern 1: The Tool Registry

The foundation of any maintainable Generative UI system is an explicit, centralized registry of components the AI can use. Do not scatter tool definitions across server actions.

// lib/genui-registry.ts
import { z } from 'zod';
import { MetricCard } from '@/components/metric-card';
import { DataTable } from '@/components/data-table';
import { BarChart } from '@/components/bar-chart';
import { AlertBanner } from '@/components/alert-banner';
import { LineChart } from '@/components/line-chart';

export const tools = {
  metricCard: {
    description: 'Display a single KPI metric with a trend indicator. Use for scalar values like revenue, user count, or conversion rate.',
    parameters: z.object({
      label: z.string().describe('The metric name, e.g. "Monthly Revenue"'),
      value: z.string().describe('The formatted value, e.g. "$12,400"'),
      change: z.number().describe('Percentage change vs. previous period'),
      period: z.string().describe('The comparison period, e.g. "vs last month"'),
    }),
    component: MetricCard,
  },
  dataTable: {
    description: 'Display tabular data with sortable columns. Use when showing lists of items with multiple attributes.',
    parameters: z.object({
      columns: z.array(z.object({
        key: z.string(),
        label: z.string(),
        numeric: z.boolean().optional(),
      })),
      rows: z.array(z.record(z.string())),
      caption: z.string().optional(),
    }),
    component: DataTable,
  },
  barChart: {
    description: 'Display a bar chart for categorical comparisons. Use when comparing values across discrete categories.',
    parameters: z.object({
      title: z.string(),
      data: z.array(z.object({ label: z.string(), value: z.number() })),
      yAxisLabel: z.string().optional(),
    }),
    component: BarChart,
  },
  lineChart: {
    description: 'Display a line chart for time-series data. Use when showing trends over time.',
    parameters: z.object({
      title: z.string(),
      data: z.array(z.object({ date: z.string(), value: z.number() })),
      unit: z.string().optional(),
    }),
    component: LineChart,
  },
  alertBanner: {
    description: 'Display an important notice, warning, or success message. Use sparingly for genuinely important information.',
    parameters: z.object({
      type: z.enum(['info', 'warning', 'error', 'success']),
      title: z.string(),
      message: z.string(),
    }),
    component: AlertBanner,
  },
};

export type ToolName = keyof typeof tools;

Key insight: The description field is what the AI reads to decide which component to use. Write descriptions for the AI, not for humans. Be specific about when each component is appropriate and, critically, when it is not.

Notice lineChart says "time-series" and barChart says "categorical." Without that distinction, the AI will make random choices between them. The more precise your descriptions, the better the component selection.

When this pattern fails. A central registry assumes a single team owns the catalog. If three product teams each want their own components, the registry becomes a coordination bottleneck — every new tool needs a PR through the platform team. The alternative is a federated registry per product surface, at the cost of duplicated descriptions and divergent quality. Pick centralization for one product, federation for a platform serving many. See the official Vercel AI SDK streamUI docs for the underlying API.

Pattern 2: Separate Registry from Streaming

Keep the registry definition separate from the streamUI call. This lets you reuse tool definitions across multiple server actions and makes the registry testable in isolation.

// lib/stream-with-tools.ts
import { streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { tools } from './genui-registry';

// Convert registry format to streamUI format
function buildStreamTools(toolNames: ToolName[]) {
  return Object.fromEntries(
    toolNames.map((name: ToolName) => [
      name,
      {
        description: tools[name].description,
        parameters: tools[name].parameters,
        generate: async function* (params: unknown) {
          yield <ToolSkeleton name={name} />;
          const Component = tools[name].component;
          // Null-guard: registry entry may be misconfigured or hot-reloaded mid-request.
          if (!Component) {
            return <GenUIFallback error={new Error(`Missing component for tool: ${name}`)} resetErrorBoundary={() => {}} />;
          }
          return <Component {...(params as Record<string, unknown>)} />;
        },
      },
    ])
  );
}

// Server action for a data dashboard
export async function generateDashboard(query: string) {
  const result = await streamUI({
    model: openai('gpt-4o'),
    system: 'You are a data analyst assistant. Display information using the appropriate visualization tool.',
    prompt: query,
    tools: buildStreamTools(['metricCard', 'dataTable', 'barChart', 'lineChart', 'alertBanner']),
  });
  return result.value;
}

// Server action for a summary view (fewer tools = better focus)
export async function generateSummary(query: string) {
  const result = await streamUI({
    model: openai('gpt-4o'),
    system: 'You are a concise assistant. Show a summary with key metrics only.',
    prompt: query,
    tools: buildStreamTools(['metricCard', 'alertBanner']),
  });
  return result.value;
}

Passing a subset of tools to each server action is important. A focused tool set produces better AI decisions. Do not give the AI 20 tools when 5 will do.

When this pattern fails. Splitting registry and streaming adds an extra layer of indirection. For a single-screen prototype with one tool, the indirection is overhead, not architecture. Inline the tool definition until the second server action exists.

Pattern 3: Streaming with Skeletons

Never show a blank screen while the AI generates. Show skeleton loading states that match the expected output shape. The visual continuity reduces perceived latency dramatically.

// components/tool-skeleton.tsx
import { ToolName } from '@/lib/genui-registry';

const SKELETON_HEIGHTS: Record<ToolName, string> = {
  metricCard: 'h-28',
  dataTable: 'h-48',
  barChart: 'h-64',
  lineChart: 'h-64',
  alertBanner: 'h-16',
};

export function ToolSkeleton({ name }: { name: ToolName }) {
  return (
    <div
      className={`animate-pulse rounded-lg bg-muted ${SKELETON_HEIGHTS[name] ?? 'h-32'} w-full`}
      aria-label="Loading..."
      aria-busy="true"
    />
  );
}

For more accurate skeletons, match the component's internal structure:

export function MetricCardSkeleton() {
  return (
    <div className="rounded-lg border bg-card p-6">
      <div className="h-4 w-24 animate-pulse rounded bg-muted" />
      <div className="mt-3 h-8 w-32 animate-pulse rounded bg-muted" />
      <div className="mt-2 h-3 w-16 animate-pulse rounded bg-muted" />
    </div>
  );
}

Matching the internal shape means the transition from skeleton to loaded component is smooth — no layout shift, no flicker.

When this pattern fails. Bespoke skeletons per component double the maintenance surface: every component update needs a matching skeleton update. For low-traffic internal tools where perceived latency does not move the business, a generic gray box is fine. Reserve hand-tuned skeletons for surfaces shown to end users on every session.

Pattern 4: Error Boundaries for Generated UI

Generated components fail in different ways than hand-coded ones. The AI might pass a numeric string where a number is expected, a negative value where only positives make sense, or an empty array to a component that requires at least one item.

Always wrap generated output in an error boundary:

// components/safe-genui.tsx
'use client';

import { ErrorBoundary } from 'react-error-boundary';

function GenUIFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4">
      <p className="text-sm font-medium text-destructive">
        This component could not render
      </p>
      <p className="mt-1 text-xs text-muted-foreground">{error.message}</p>
      <button
        onClick={resetErrorBoundary}
        className="mt-2 text-xs underline text-muted-foreground"
      >
        Try again
      </button>
    </div>
  );
}

export function SafeGenUI({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundary FallbackComponent={GenUIFallback}>
      {children}
    </ErrorBoundary>
  );
}

Wrap every piece of generated output with <SafeGenUI>. A rendering error in one component should not break the entire response. The react-error-boundary library handles the underlying mechanics.

When this pattern fails. Error boundaries swallow exceptions. If you do not pipe error.message to an observability tool (Sentry, GlitchTip, Datadog), the same bug will fire silently in production for weeks. Boundaries without logging are worse than no boundary, because they hide the symptom.

Pattern 5: State Management for Generated Interactions

Components the AI generates often need to be interactive — a table that can be sorted, a chart with tooltips, a form that submits data. This interactivity lives in the component itself and does not require special consideration.

What does require thought is when the generated UI needs to affect the application state outside of itself:

// Using React context to let generated components interact with the app
export const AppStateContext = createContext<{
  onDataSelected: (data: unknown) => void;
  onActionTriggered: (action: string, params: unknown) => void;
} | null>(null);

// In your generated component
function DataTable({ columns, rows }: DataTableProps) {
  const appState = useContext(AppStateContext);

  function handleRowClick(row: Record<string, string>) {
    appState?.onDataSelected(row);
  }

  return (
    <table>
      {/* ... */}
      {rows.map((row, i) => (
        <tr key={i} onClick={() => handleRowClick(row)} className="cursor-pointer hover:bg-muted">
          {/* ... */}
        </tr>
      ))}
    </table>
  );
}

Design your generated components with a clear API for external interactions. Pass callback props from a context rather than importing global state directly — generated components should be portable.

When this pattern fails. Context-based state coupling makes generated components untestable in isolation: you now need to mount a provider in every Storybook story. If only one or two components need external state, prop drilling is honest. Reach for context once three or more components share the same outbound interface.

Pattern Selection Matrix

For engineering managers picking which patterns to mandate first, the trade-offs cluster like this:

PatternCost to addPayoffSkip if
Registry1 dayMultiplies as catalog grows; required for testabilityYou have 1 tool, ever
Registry/streaming split2 hoursReuse across surfaces; isolated unit testsSingle server action
Skeletons1 day per component (bespoke), 1 hour (generic)Perceived latency on streaming; required for slow modelsInternal-only tools, no SLA
Error boundary2 hours + logging wiringMandatory for production; without it every prop bug is a white-screenNever — always ship this
External state0.5–2 daysRequired for GenUI that triggers app actionsRead-only displays

The error-boundary row is the only non-negotiable. The other four are sequenced by team size: solo engineer ships skeletons last; a 5-person team ships the registry on day one because the coordination cost of not having one is higher than the cost of building one.

TCO by team size

A back-of-envelope total-cost-of-ownership over 12 months, assuming GPT-4o-class inference and a moderate-traffic product (10k generations/day). These are first-order estimates — calibrate against your own telemetry before committing.

Team sizeBuild (eng-weeks)Inference ($/month)Ops + on-call (eng-hours/month)
Solo (indie)2–3 weeks$150–$4004–8
Small team (3–5)4–6 weeks$400–$1,2008–16
Mid team (10+)8–12 weeks$1,200–$5,000+16–40

Inference dominates at scale. The cheapest lever is reducing tool count per server action (Pattern 2) and caching identical prompts; the second cheapest is routing simple queries to a smaller model.

Team Adoption Roadmap

Week 1–2: ship Pattern 4 (error boundaries) and Pattern 1 (registry) with two or three tools behind a feature flag for 5 percent of users. Week 3–4: add Pattern 3 (skeletons) and Pattern 2 (separation); expand to 25 percent. Week 5–8: add Pattern 5 (state); roll to 100 percent. Hold the rollout at each gate until p95 latency, error rate, and inference cost per session are inside your published SLOs. Do not add more tools to the registry until the first set is stable.

Deploy Your GenUI App (Indie Path)

If you are a solo engineer trying to ship a GenUI feature this weekend, here is the shortest credible path:

  1. Start from create-next-app with the App Router. Install ai, @ai-sdk/openai, zod, and react-error-boundary.
  2. Skip Pattern 2 for the first version — inline two tools directly in your server action.
  3. Use the generic gray-box skeleton from Pattern 3, not the bespoke variants. Ship the bespoke ones after the feature has users.
  4. Wrap the streamed output in <SafeGenUI> from Pattern 4. This is non-negotiable.
  5. Deploy on Vercel's free or Pro tier. Add OPENAI_API_KEY to environment variables. First deploy is git push.
  6. Set a hard usage cap on the OpenAI key (the OpenAI dashboard supports monthly limits) so a runaway loop cannot drain your budget overnight.

Indie cost estimate for a hobby project (1,000 generations/month): roughly $5–$15 on inference, $0 on hosting at Vercel hobby tier, $0 on monitoring with Vercel's built-in logs. By our estimate, the first meaningful bill starts arriving at ~50k generations/month; that is when Pattern 2 (split server actions) and prompt caching start paying for themselves.

A simplified registry for indie scope — a server action with one tool and a skeleton, ready to paste:

// app/actions.tsx
'use server'
import { streamUI } from 'ai/rsc'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
import { MetricCard } from '@/components/metric-card'
import { Skeleton } from '@/components/skeleton'

const metricSchema = z.object({
  value: z.number().describe('current numeric value of the metric'),
  label: z.string().describe('human-readable metric name'),
  delta: z.number().describe('percent change vs previous period'),
})

export async function generateUI(prompt: string) {
  const result = await streamUI({
    model: openai('gpt-4o-mini'),
    prompt,
    tools: {
      metricCard: {
        description: 'Show a single KPI value with a delta',
        parameters: metricSchema,
        generate: async function* (p: z.infer<typeof metricSchema>) {
          yield <Skeleton className="h-28 rounded bg-muted" />
          return <MetricCard {...p} period="vs last month" />
        },
      },
    },
  })
  return result.value
}

And a one-form client call:

// app/page.tsx
'use client'
import { useState } from 'react'
import { generateUI } from './actions'

export default function Page() {
  const [ui, setUI] = useState<React.ReactNode>(null)
  return (
    <form action={async (formData) => {
      setUI(await generateUI(formData.get('q') as string))
    }}>
      <input name="q" />
      <button>Generate</button>
      <div>{ui}</div>
    </form>
  )
}

Graduate to the full patterns once the feature has paying users or three or more tools in the catalog.

Common Mistakes

Too many tools. If you give the AI 50 components to choose from, it will make poor choices. I have seen teams start with 20+ tools, then find the AI consistently picks the wrong ones. Start with 5–8 well-defined tools and expand only based on data showing which queries are unmatched.

Vague descriptions. "Display data" is not a useful tool description. "Display tabular data with sortable columns when showing lists of items with multiple attributes" tells the AI exactly when to use it.

No fallback. When the AI model is down or returns an error, users see nothing. Always have a static fallback UI for critical paths. If you are using Generative UI for a data dashboard, have a default static view that loads when the AI is unavailable.

Skipping Zod validation. The AI will occasionally pass unexpected props — a string where a number is expected, a null where a value is required. Strict Zod validation catches these before they reach your component.

Over-generating. Not every interaction needs Generative UI. If a static component works, use it. GenUI adds 200–800ms of latency and costs money. Use it for the interactions where the variability is genuinely valuable.

Not logging tool calls. Without logging which tools the AI selects and what parameters it passes, you have no data for improvement. Log everything from day one. The patterns you see after a week of usage will change how you write your tool descriptions.

Production Checklist

Before shipping Generative UI to production:

  • All generated components wrapped in error boundaries
  • Skeleton loading states for every tool
  • Static fallback when AI is unavailable or returns an error
  • Strict Zod validation on all tool parameters
  • Tool call logging in place (tool name, parameters, latency)
  • Latency monitoring (alert if >2s to first component)
  • Cost tracking per AI inference
  • Accessibility audit of all generated component compositions
  • Mobile responsive testing of generated layouts
  • Rate limiting on the server action

A Note on Testing

Testing Generative UI requires a different approach than traditional UI testing. The short version:

  • Test your components in isolation with standard unit tests — they are just React components
  • Test your Zod schemas separately to ensure they accept valid and reject invalid inputs
  • For integration tests against the AI, test structural properties (correct tool called, valid parameters) not exact content (the temperature is 22°)
  • Mock the AI in CI and run real AI integration tests nightly

This topic deserves its own article. For now, the patterns covered in Building Generative UI with Vercel AI SDK include the validation and error-handling foundations that make tests reliable.

Alternatives Considered

The patterns above assume Vercel AI SDK with React Server Components. Two alternatives worth knowing about before you commit:

  • Tambo / a component-catalog runtime. An opensource framework for AI-generated UI on React (github.com/tambo-ai/tambo, ~11k stars as of 2026-05) ships faster (no registry code to write) and centralizes description quality. Use it when speed-to-first-demo matters more than long-term unit cost.
  • Declarative JSON protocols like Thesys C1 (closed API) or A2UI v0.9 (Google's open spec, November 2025) decouple the model from React entirely; any client (web, mobile, voice) can render the same payload. Use these when you have non-web surfaces, at the cost of building your own renderer.
  • Plain JSON + handwritten dispatcher. No SDK at all. You write a switch statement over tool names. Cheapest at small scale, hardest to maintain past five tools.

The choice axis is moat-and-portability vs. time-to-ship. For most React-only products the SDK path in this article wins; for multi-surface or vendor-neutral products, evaluate A2UI.

Further Reading


Working on a React Generative UI implementation? Get expert guidance on architecture, performance, and production readiness.

ShareTwitterLinkedInEmail
reactgenerative-uipatternsimplementation
A

Alex

Generative UI Engineer & Consultant

Senior engineer specializing in AI-powered interfaces and Generative UI systems. Helping product teams ship faster with the right GenUI stack.

Stay ahead on Generative UI

Weekly articles, framework updates, and practical implementation guides — straight to your inbox.

We respect your privacy. Unsubscribe anytime.

Need help implementing what you just read?

Book a Free Consultation