Building Generative UI with LLMs and React: Beyond the Chatbox
Move beyond boring text streaming. Learn how to build production-ready, dynamic generative UI using Gemini, React Server Components, and Zod schemas.
Chatbots are a lazy design pattern. Slapping a terminal-like text input in the bottom right corner of your SaaS app and calling it "AI integration" is a disservice to your users. When Gemini Pro 3 can dynamically imagine and structure complex user interfaces on the fly, expecting users to read paragraphs of markdown text is an outdated approach.
The future of application design is dynamic, context-aware interfaces that adapt to user intent in real-time. If you want to build interfaces that feel truly intelligent, you need to master building generative UI with LLMs and React.
This guide bypasses the high-level hand-waving and dives straight into the production-ready architecture, type safety strategies, and code patterns required to stream dynamic React components directly from LLM outputs.
Why Raw HTML Generation is a Security and UX Nightmare
Before writing any code, we need to address the wrong way to build generative UI.
Many developers start by asking the LLM to generate raw HTML, CSS, or React code as a string, which they then render using dangerouslySetInnerHTML or an on-the-fly compiler like eval().
Do not do this in production.
- Security (XSS): If your LLM is hijacked via prompt injection, it can output malicious
<script>tags, stealing user sessions and executing arbitrary client-side code. - Performance: Compiling raw React components on the client-side bloats your bundle size and kills render performance.
- Design System Drift: LLMs are notoriously bad at adhering to strict design guidelines. Let them generate raw CSS, and your application will quickly look like a chaotic Geocities page.
The correct approach is Structured Component Hydration. Instead of letting the LLM write code, the LLM emits highly structured JSON (validated by a schema). Your frontend then maps this structured data to a library of pre-built, strictly typed React components.
Architecting a System for Building Generative UI with LLMs and React
To build a reliable generative UI pipeline, you need a deterministic bridge between the non-deterministic output of an LLM and the strict type system of React.
Here is how the data flows from a user's natural language request to a fully interactive React component:
By leveraging structured outputs, we ensure that the LLM only returns data matching our exact component prop types. If the LLM tries to hallucinate a prop that doesn't exist, the validation layer catches it before it ever hits your component tree.
A Production-Ready Codebase for Building Generative UI with LLMs and React
Let's build a real-world example: an AI-driven financial dashboard. Depending on what the user asks ("Show me my portfolio performance" vs. "Compare tech stocks"), the application will dynamically render completely different interactive charts and widgets.
We will use TypeScript, Next.js App Router, Zod, and the Vercel AI SDK (which provides the cleanest primitives for streaming UI elements).
1. Defining the Component Library
First, we define the static React components that our LLM can choose to render. These live in our codebase, meaning they are fully compiled, optimized, and secure.
// components/stocks/stock-ticker.tsx
'use client';
export interface StockTickerProps {
symbol: string;
price: number;
change: number;
}
export function StockTicker({ symbol, price, change }: StockTickerProps) {
const isPositive = change >= 0;
return (
<div className="p-4 rounded-xl border border-zinc-800 bg-zinc-950 flex justify-between items-center w-full max-w-sm">
<div>
<h3 className="font-bold text-zinc-100">{symbol}</h3>
<p className="text-xs text-zinc-400">Real-time quote</p>
</div>
<div className="text-right">
<p className="font-mono font-semibold text-zinc-100">${price.toFixed(2)}</p>
<p className={`text-xs font-mono ${isPositive ? 'text-emerald-500' : 'text-rose-500'}`}>
{isPositive ? '+' : ''}{change.toFixed(2)}%
</p>
</div>
</div>
);
}// components/stocks/portfolio-chart.tsx
'use client';
export interface PortfolioChartProps {
assets: { name: string; value: number; color: string }[];
totalValue: number;
}
export function PortfolioChart({ assets, totalValue }: PortfolioChartProps) {
return (
<div className="p-6 rounded-xl border border-zinc-800 bg-zinc-950 w-full max-w-md">
<h3 className="text-sm font-medium text-zinc-400 mb-1">Total Portfolio Value</h3>
<p className="text-2xl font-bold text-zinc-100 mb-4">${totalValue.toLocaleString()}</p>
<div className="flex h-4 rounded-full overflow-hidden bg-zinc-800 mb-4">
{assets.map((asset) => {
const percentage = (asset.value / totalValue) * 100;
return (
<div
key={asset.name}
style={{ width: `${percentage}%`, backgroundColor: asset.color }}
className="h-full transition-all"
title={`${asset.name}: $${asset.value}`}
/>
);
})}
</div>
<div className="grid grid-cols-2 gap-2">
{assets.map((asset) => (
<div key={asset.name} className="flex items-center gap-2 text-xs">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: asset.color }} />
<span className="text-zinc-300">{asset.name}</span>
<span className="text-zinc-500 ml-auto">${asset.value.toLocaleString()}</span>
</div>
))}
</div>
</div>
);
}2. Setting Up the AI Server Action
Now, we create the Server Action that interfaces with the Gemini API (or OpenAI). This action processes the prompt, determines which component needs to be rendered, validates the LLM's arguments using Zod, and returns the live React component.
We use streamUI from the Vercel AI SDK to stream the node directly to the client.
// app/actions.tsx
'use server';
import { createAI, streamUI } from 'ai/rsc';
import { google } from '@ai-sdk/google'; // Or openai/anthropic
import { z } from 'zod';
import { ReactNode } from 'react';
import { StockTicker, StockTickerProps } from '@/components/stocks/stock-ticker';
import { PortfolioChart, PortfolioChartProps } from '@/components/stocks/portfolio-chart';
// Helper to simulate DB/API fetches
async function fetchStockPrice(symbol: string): Promise<StockTickerProps> {
const mockPrices: Record<string, number> = { GOOG: 175.43, AAPL: 189.84, MSFT: 421.90 };
const price = mockPrices[symbol.toUpperCase()] || 100.00;
const change = (Math.random() * 10) - 5;
return { symbol: symbol.toUpperCase(), price, change };
}
export async function submitUserMessage(userInput: string): Promise<ReactNode> {
'use server';
const ui = await streamUI({
model: google('gemini-1.5-pro'),
prompt: userInput,
text: ({ content }) => <p className="text-zinc-300 leading-relaxed">{content}</p>,
tools: {
showStockTicker: {
description: 'Render a stock ticker widget for a specific stock symbol.',
parameters: z.object({
symbol: z.string().describe('The stock ticker symbol, e.g., AAPL, GOOG, MSFT'),
}),
generate: async function* ({ symbol }) {
yield <div className="animate-pulse bg-zinc-900 h-20 w-80 rounded-xl" />;
const data = await fetchStockPrice(symbol);
return <StockTicker {...data} />;
},
},
showPortfolio: {
description: 'Render the user financial portfolio asset allocation chart.',
parameters: z.object({
assets: z.array(
z.object({
name: z.string(),
value: z.number(),
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Must be a valid hex color'),
})
),
}),
generate: async function* ({ assets }) {
yield <div className="animate-pulse bg-zinc-900 h-48 w-96 rounded-xl" />;
const totalValue = assets.reduce((sum, asset) => sum + asset.value, 0);
return <PortfolioChart assets={assets} totalValue={totalValue} />;
},
},
},
});
return ui.value;
}3. Creating the Client Interface
On the client side, we simply call this Server Action and append the returned ReactNode directly into our state array. Because Next.js handles React Server Components natively, the dynamic components stream in and hydrate seamlessly with zero client-side compilation overhead.
// app/page.tsx
'use client';
import { useState, useTransition } from 'react';
import { submitUserMessage } from './actions';
export default function Home() {
const [input, setInput] = useState('');
const [conversation, setConversation] = useState<{ id: number; display: React.ReactNode }[]>([]);
const [isPending, startTransition] = useTransition();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
ifRelated Posts
Beyond the Chatbox: How to Build Generative UI with LLMs and TypeScript
Learn how to build generative ui with llms using TypeScript. Step-by-step guide to building a neural expressive interface that bypasses the dead chat-log pattern.
Upgrading to React 19.2.7: Fixing Hydration Mismatches and Action State Edge Cases
A deep dive into upgrading to React 19.2.7, exploring the critical bug fixes for hydration mismatches, Server Component ref handoffs, and Action Hook regressions.
How to Run Claude Managed Agents with Vercel Sandbox
Learn how to securely run Claude managed agents with Vercel Sandbox. Build a production-ready, sandboxed execution environment for LLM-generated code using TypeScript.