Generative UI shifts the mental model from “chat with an LLM” to “render components the LLM chooses.” The Vercel AI SDK makes this practical in the Next.js App Router by combining React Server Components, streaming, and tool calling into a single pattern. This tutorial builds a working example you can extend.
Prerequisites
- Node.js 20+
- A Next.js 14+ project with App Router (
npx create-next-app@latest --typescript --tailwind --eslint --app) - An OpenAI-compatible API key (or any provider the AI SDK supports)
- Familiarity with React Server Components and async/await
Install the AI SDK packages:
npm install ai @ai-sdk/openai zod
Project structure
app/
├── api/
│ └── chat/
│ └── route.ts # Server endpoint that streams tool calls
├── components/
│ ├── Chat.tsx # Client component handling the conversation
│ ├── WeatherCard.tsx # Generative UI component #1
│ └── StockChart.tsx # Generative UI component #2
├── actions.ts # Server actions for tool execution
└── page.tsx # Page composition
Define the tool schema
Tools are the contract between the model and your UI. Use Zod for validation — the AI SDK infers the JSON schema automatically.
// app/actions.ts
import { z } from "zod";
import { createTool } from "ai";
export const getWeather = createTool({
parameters: z.object({
location: z.string().describe("City and state, e.g. 'San Francisco, CA'"),
unit: z.enum(["celsius", "fahrenheit"]).default("fahrenheit"),
}),
execute: async ({ location, unit }) => {
// In production, call a real weather API
const temp = unit === "celsius" ? 18 : 64;
return {
location,
temperature: temp,
unit,
condition: "Partly cloudy",
humidity: 65,
};
},
});
export const getStockPrice = createTool({
parameters: z.object({
symbol: z.string().describe("Stock ticker symbol, e.g. 'AAPL'"),
}),
execute: async ({ symbol }) => {
// In production, call a real market data API
const prices: Record<string, number> = {
AAPL: 182.52,
GOOGL: 141.38,
MSFT: 404.23,
TSLA: 248.15,
};
return {
symbol: symbol.toUpperCase(),
price: prices[symbol.toUpperCase()] ?? 100.00,
change: (Math.random() - 0.5) * 10,
timestamp: new Date().toISOString(),
};
},
});
The execute function runs on the server. Keep it pure — no side effects beyond the external API call.
Build the generative UI components
Each tool maps to a React component. These render on the server and stream to the client as part of the assistant message.
// app/components/WeatherCard.tsx
interface WeatherData {
location: string;
temperature: number;
unit: "celsius" | "fahrenheit";
condition: string;
humidity: number;
}
export function WeatherCard({ data }: { data: WeatherData }) {
const unitSymbol = data.unit === "celsius" ? "°C" : "°F";
return (
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-baseline gap-2">
<h3 className="text-lg font-semibold text-slate-900">{data.location}</h3>
<span className="text-sm text-slate-500">{data.condition}</span>
</div>
<div className="mt-2 flex items-end gap-4">
<span className="text-4xl font-mono font-bold text-slate-900">
{data.temperature}{unitSymbol}
</span>
<div className="text-sm text-slate-500">
<p>Humidity: {data.humidity}%</p>
</div>
</div>
</div>
);
}
// app/components/StockChart.tsx
interface StockData {
symbol: string;
price: number;
change: number;
timestamp: string;
}
export function StockChart({ data }: { data: StockData }) {
const isPositive = data.change >= 0;
const changeClass = isPositive ? "text-green-600" : "text-red-600";
const changePrefix = isPositive ? "+" : "";
return (
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex justify-between items-baseline">
<h3 className="text-lg font-semibold text-slate-900">{data.symbol}</h3>
<time className="text-xs text-slate-400">
{new Date(data.timestamp).toLocaleTimeString()}
</time>
</div>
<div className="mt-2 flex items-baseline gap-3">
<span className="text-3xl font-mono font-bold text-slate-900">
${data.price.toFixed(2)}
</span>
<span className={`text-sm font-medium ${changeClass}`}>
{changePrefix}{data.change.toFixed(2)}%
</span>
</div>
<div className="mt-3 h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${isPositive ? "bg-green-500" : "bg-red-500"}`}
style={{ width: `${Math.min(Math.abs(data.change) * 10, 100)}%` }}
/>
</div>
</div>
);
}
These are plain server components. No use client directive — they render during the stream.
Create the chat API route
The route uses streamText with the tools option. The AI SDK handles tool calling, result injection, and streaming the final response with embedded component data.
// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { getWeather, getStockPrice } from "@/app/actions";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather,
getStockPrice,
},
system: `You are a helpful assistant that can check weather and stock prices.
When users ask about weather or stocks, call the appropriate tool.
The tool results will be rendered as interactive components automatically.`,
});
return result.toDataStreamResponse();
}
The toDataStreamResponse() method returns a ReadableStream that the client consumes. It includes both text deltas and tool result objects.
Build the client chat component
The client component uses useChat from ai/react. It handles the message list, input, and streaming state.
// app/components/Chat.tsx
"use client";
import { useChat } from "ai/react";
import { WeatherCard } from "./WeatherCard";
import { StockChart } from "./StockChart";
export function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: "/api/chat",
});
return (
<div className="flex flex-col h-[600px] rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${message.role === "assistant" ? "" : "justify-end"}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-2 ${
message.role === "user"
? "bg-blue-600 text-white rounded-br-none"
: "bg-slate-100 text-slate-900 rounded-bl-none"
}`}
>
{message.role === "assistant" && message.parts?.map((part, i) => {
if (part.type === "text") {
return <p key={i} className="whitespace-pre-wrap">{part.text}</p>;
}
if (part.type === "tool-result") {
if (part.toolName === "getWeather") {
return <WeatherCard key={i} data={part.result} />;
}
if (part.toolName === "getStockPrice") {
return <StockChart key={i} data={part.result} />;
}
}
return null;
})}
{message.role === "user" && (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3 justify-start">
<div className="bg-slate-100 text-slate-900 rounded-2xl px-4 py-2 rounded-bl-none">
<div className="flex gap-1">
<span className="animate-bounce">●</span>
<span className="animate-bounce" style={{ animationDelay: "0.1s" }}>●</span>
<span className="animate-bounce" style={{ animationDelay: "0.2s" }}>●</span>
</div>
</div>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="border-t border-slate-200 p-4">
<div className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask about weather or stock prices..."
className="flex-1 rounded-lg border border-slate-300 bg-white px-4 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="rounded-lg bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</form>
</div>
);
}
Key points:
message.partscontains the streamed segments — text and tool results interleaved- Tool results arrive as
{ type: "tool-result", toolName, result }objects - The component renders the appropriate UI component for each tool result
Wire it into the page
// app/page.tsx
import { Chat } from "@/app/components/Chat";
export default function Home() {
return (
<main className="min-h-screen bg-slate-50 p-8">
<div className="max-w-3xl mx-auto">
<header className="mb-8 text-center">
<h1 className="text-3xl font-bold text-slate-900">Generative UI Demo</h1>
<p className="mt-2 text-slate-600">
Ask about weather or stock prices. The assistant renders live components.
</p>
</header>
<Chat />
</div>
</main>
);
}
Run and verify
npm run dev
Open http://localhost:3000. Try these prompts:
- “What’s the weather in Seattle?”
- “Show me AAPL and TSLA stock prices”
- “Compare weather in Miami and San Francisco”
You should see the assistant respond with text, then the tool calls execute, and the WeatherCard or StockChart components render inline within the message bubble — streamed as they complete.
How the streaming works
- Client sends messages to
/api/chat streamTextcalls the model with tools defined- Model emits a tool call →
streamTextexecutes the tool on the server - Tool result streams back as a
tool-resultpart - Client receives the part via the data stream and renders the mapped component
- Model continues generating text with the tool result in context
The toDataStreamResponse() format is a newline-delimited JSON stream. Each line is a protocol message: text-delta, tool-call, tool-result, finish, etc. The useChat hook parses this automatically.
Handling multiple tool calls in parallel
The model may emit multiple tool calls in one turn. streamText executes them concurrently. Results stream back as they resolve — no waiting for the slowest call.
// In actions.ts, simulate variable latency
execute: async ({ location }) => {
await new Promise((r) => setTimeout(r, Math.random() * 1000 + 500));
return { ... };
}
The UI renders each card the moment its result arrives. This is the key advantage of streaming generative UI over blocking on a full response.
Error handling
Tools can throw. Wrap execution and return structured errors the model can reason about.
export const getWeather = createTool({
// ...
execute: async ({ location }) => {
try {
const response = await fetch(`https://api.weather.com/...`);
if (!response.ok) throw new Error("Weather API error");
return response.json();
} catch (error) {
return { error: "Failed to fetch weather", location };
}
},
});
The component receives the error object and can render a fallback:
if (part.type === "tool-result" && part.result.error) {
return (
<div className="rounded-lg bg-red-50 p-3 text-red-700 text-sm">
Could not load {part.toolName}: {part.result.error}
</div>
);
}
Adding a third tool: user location
Server actions can access headers, cookies, and request context. Use this for personalization.
// app/actions.ts
import { headers } from "next/headers";
export const getLocalWeather = createTool({
parameters: z.object({}),
execute: async () => {
const headersList = await headers();
const forwarded = headersList.get("x-forwarded-for");
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
// In production, use a geolocation service
return getWeather.execute({ location: "San Francisco, CA" });
},
});
Add it to the route’s tools object. The model now has a zero-argument tool for “weather here.”
Production considerations
- Rate limiting: Wrap the route with middleware or use Upstash Redis for per-IP limits
- Authentication: Check
req.headers.get("authorization")in the route before callingstreamText - Observability: Log tool calls, latency, and token usage. The AI SDK exposes
result.usageafter the stream finishes - Caching: Weather and stock data are cacheable. Use
next: falsein the tool definition if you need fresh data every time, or implement your own caching layer - Provider fallback: If you route through a gateway like n4n.ai, configure automatic fallback when a provider is degraded — the route code stays the same
Extending the pattern
This architecture scales to complex UIs:
| Tool | Component | Use case |
|---|---|---|
searchProducts |
ProductGrid |
E-commerce recommendations |
queryDatabase |
DataTable |
Analytics dashboards |
generateChart |
ChartJS / Recharts |
Dynamic visualizations |
createCalendarEvent |
CalendarPreview |
Scheduling assistants |
Each tool returns typed data. Each component renders that type. The model decides which to call based on user intent.
Summary
You now have a working generative UI system:
- Tools defined with Zod schemas and server-side execution
- React Server Components that render tool results
- A streaming API route that interleaves text and component data
- A client hook that assembles the stream into a conversation
The pattern is minimal — no custom protocol, no WebSocket server, no client-side state machine. The AI SDK handles the streaming protocol; React handles the rendering. Your code defines the tools and their UI.