The Vercel AI SDK’s generative UI pattern lets models stream React components directly to the client, not just text. This tutorial builds a stock chart interface where the LLM renders interactive charts on demand — no separate API layer, no manual parsing. You’ll wire up a Next.js app with React Server Components, stream chart components from the model, and handle tool calls that fetch real market data.
Prerequisites
- Node.js 20+ and pnpm (or npm/yarn)
- A Vercel account for deployment, or run locally
- An OpenAI API key (or any provider supported by the AI SDK)
- Basic familiarity with Next.js App Router and React Server Components
Initialize the project:
pnpm create next-app@latest generative-stock-chart --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd generative-stock-chart
pnpm add ai @ai-sdk/openai recharts zod
pnpm add -D @types/recharts
We use Recharts for the chart components — it’s server-renderable and works well with the AI SDK’s component streaming.
Project structure
src/
├── app/
│ ├── api/chat/route.ts # Server-side chat handler
│ ├── page.tsx # Client entry point
│ └── layout.tsx
├── components/
│ ├── stock-chart.tsx # Recharts wrapper
│ ├── chat-interface.tsx # Client chat UI
│ └── chart-renderer.tsx # Renders streamed chart components
├── lib/
│ ├── tools.ts # Tool definitions for market data
│ └── types.ts # Shared TypeScript types
└── providers/
└── ai-provider.tsx # AI SDK provider setup
Create the directories:
mkdir -p src/components src/lib src/providers src/app/api/chat
Define shared types
Start with the data shapes the model will work with.
// src/lib/types.ts
export interface StockDataPoint {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface ChartProps {
symbol: string;
data: StockDataPoint[];
timeframe: '1D' | '1W' | '1M' | '3M' | '1Y' | '5Y';
indicators?: ('sma20' | 'sma50' | 'ema20' | 'bollinger' | 'rsi')[];
}
export interface ToolResult<T = unknown> {
success: boolean;
data?: T;
error?: string;
}
Build the stock chart component
This component renders on the server and streams as HTML. Keep it pure — no client-side interactivity needed for the initial render.
// src/components/stock-chart.tsx
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
AreaChart,
Area,
CandlestickChart,
Candlestick,
} from 'recharts';
import { ChartProps } from '@/lib/types';
const COLORS = {
primary: '#3b82f6',
secondary: '#60a5fa',
grid: '#e5e7eb',
text: '#6b7280',
up: '#22c55e',
down: '#ef4444',
};
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function calculateSMA(data: number[], period: number): (number | null)[] {
const result: (number | null)[] = [];
for (let i = 0; i < data.length; i++) {
if (i < period - 1) {
result.push(null);
} else {
const slice = data.slice(i - period + 1, i + 1);
const sum = slice.reduce((a, b) => a + b, 0);
result.push(sum / period);
}
}
return result;
}
function calculateBollinger(data: number[], period: number, stdDev: number = 2) {
const sma = calculateSMA(data, period);
const upper: (number | null)[] = [];
const lower: (number | null)[] = [];
for (let i = 0; i < data.length; i++) {
if (i < period - 1) {
upper.push(null);
lower.push(null);
} else {
const slice = data.slice(i - period + 1, i + 1);
const mean = sma[i] as number;
const variance = slice.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / period;
const sd = Math.sqrt(variance);
upper.push(mean + stdDev * sd);
lower.push(mean - stdDev * sd);
}
}
return { upper, lower, middle: sma };
}
export function StockChart({ symbol, data, timeframe, indicators = [] }: ChartProps) {
if (!data.length) {
return (
<div className="w-full h-64 flex items-center justify-center text-gray-500 bg-gray-50 rounded-lg border">
No data available for {symbol}
</div>
);
}
const closes = data.map(d => d.close);
const dates = data.map(d => formatDate(d.date));
const sma20 = indicators.includes('sma20') ? calculateSMA(closes, 20) : null;
const sma50 = indicators.includes('sma50') ? calculateSMA(closes, 50) : null;
const bollinger = indicators.includes('bollinger') ? calculateBollinger(closes, 20) : null;
const latestClose = closes[closes.length - 1];
const prevClose = closes[closes.length - 2] ?? latestClose;
const change = latestClose - prevClose;
const changePct = ((change / prevClose) * 100).toFixed(2);
const isUp = change >= 0;
return (
<div className="w-full space-y-2">
<div className="flex items-baseline justify-between">
<div>
<h3 className="text-lg font-semibold">{symbol.toUpperCase()}</h3>
<span className="text-sm text-gray-500">{timeframe} • {data.length} periods</span>
</div>
<div className="text-right">
<div className="text-2xl font-bold">{latestClose.toFixed(2)}</div>
<div className={`text-sm font-medium ${isUp ? 'text-green-600' : 'text-red-600'}`}>
{isUp ? '+' : ''}{change.toFixed(2)} ({isUp ? '+' : ''}{changePct}%)
</div>
</div>
</div>
<div className="w-full h-64 bg-white rounded-lg border">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorPrice" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={COLORS.primary} stopOpacity={0.3} />
<stop offset="95%" stopColor={COLORS.primary} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={COLORS.grid} vertical={false} />
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fill: COLORS.text, fontSize: 11 }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fill: COLORS.text, fontSize: 11 }}
axisLine={false}
tickLine={false}
width={60}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
labelFormatter={formatDate}
formatter={(value: number) => [`$${value.toFixed(2)}`, 'Price']}
/>
<Area
type="monotone"
dataKey="close"
stroke={COLORS.primary}
strokeWidth={2}
fillOpacity={1}
fill="url(#colorPrice)"
/>
{sma20 && (
<Line
type="monotone"
dataKey="sma20"
stroke="#f59e0b"
strokeWidth={1.5}
strokeDasharray="5 5"
dot={false}
data={data.map((d, i) => ({ ...d, sma20: sma20[i] }))}
/>
)}
{sma50 && (
<Line
type="monotone"
dataKey="sma50"
stroke="#8b5cf6"
strokeWidth={1.5}
strokeDasharray="5 5"
dot={false}
data={data.map((d, i) => ({ ...d, sma50: sma50[i] }))}
/>
)}
{bollinger && (
<>
<Area
type="monotone"
dataKey="bollingerUpper"
stroke="transparent"
strokeWidth={0}
fill="#8b5cf6"
fillOpacity={0.08}
data={data.map((d, i) => ({ ...d, bollingerUpper: bollinger.upper[i] }))}
/>
<Area
type="monotone"
dataKey="bollingerLower"
stroke="transparent"
strokeWidth={0}
fill="#8b5cf6"
fillOpacity={0.08}
data={data.map((d, i) => ({ ...d, bollingerLower: bollinger.lower[i] }))}
/>
<Line
type="monotone"
dataKey="bollingerMiddle"
stroke="#8b5cf6"
strokeWidth={1}
strokeDasharray="3 3"
dot={false}
data={data.map((d, i) => ({ ...d, bollingerMiddle: bollinger.middle[i] }))}
/>
</>
)}
</AreaChart>
</ResponsiveContainer>
</div>
{indicators.length > 0 && (
<div className="flex flex-wrap gap-2 text-xs text-gray-500">
{indicators.map(ind => (
<span key={ind} className="px-2 py-1 bg-gray-100 rounded">
{ind.toUpperCase()}
</span>
))}
</div>
)}
</div>
);
}
Create the market data tools
The model needs tools to fetch real data. We’ll use a free API (Alpha Vantage or Yahoo Finance via a proxy). For this tutorial, we’ll simulate with a reliable free endpoint.
// src/lib/tools.ts
import { tool } from 'ai';
import { z } from 'zod';
import { StockDataPoint, ToolResult } from '@/lib/types';
const TIMEFRAME_MAP: Record<string, { interval: string; range: string }> = {
'1D': { interval: '5m', range: '1d' },
'1W': { interval: '30m', range: '5d' },
'1M': { interval: '1h', range: '1mo' },
'3M': { interval: '1d', range: '3mo' },
'1Y': { interval: '1d', range: '1y' },
'5Y': { interval: '1wk', range: '5y' },
};
async function fetchYahooData(symbol: string, timeframe: keyof typeof TIMEFRAME_MAP): Promise<ToolResult<StockDataPoint[]>> {
const { interval, range } = TIMEFRAME_MAP[timeframe];
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${symbol.toUpperCase()}?interval=${interval}&range=${range}`;
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; StockChartBot/1.0)' },
next: { revalidate: 300 }, // Cache for 5 minutes
});
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}: ${response.statusText}` };
}
const json = await response.json();
const result = json.chart?.result?.[0];
if (!result) {
return { success: false, error: 'No data returned for symbol' };
}
const timestamps = result.timestamp || [];
const quotes = result.indicators?.quote?.[0] || {};
const opens = quotes.open || [];
const highs = quotes.high || [];
const lows = quotes.low || [];
const closes = quotes.close || [];
const volumes = quotes.volume || [];
const data: StockDataPoint[] = timestamps.map((ts: number, i: number) => ({
date: new Date(ts * 1000).toISOString().split('T')[0],
open: opens[i] ?? 0,
high: highs[i] ?? 0,
low: lows[i] ?? 0,
close: closes[i] ?? 0,
volume: volumes[i] ?? 0,
})).filter(d => d.close > 0);
return { success: true, data };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export const getStockData = tool({
parameters: z.object({
symbol: z.string().describe('Stock ticker symbol (e.g., AAPL, MSFT, GOOGL)'),
timeframe: z.enum(['1D', '1W', '1M', '3M', '1Y', '5Y']).default('1M').describe('Time period for the chart'),
}),
execute: async ({ symbol, timeframe = '1M' }) => {
return fetchYahooData(symbol, timeframe);
},
});
export const compareStocks = tool({
parameters: z.object({
symbols: z.array(z.string()).min(2).max(5).describe('Array of stock ticker symbols to compare'),
timeframe: z.enum(['1D', '1W', '1M', '3M', '1Y', '5Y']).default('1M'),
}),
execute: async ({ symbols, timeframe = '1M' }) => {
const results = await Promise.all(
symbols.map(sym => fetchYahooData(sym, timeframe))
);
return {
success: results.every(r => r.success),
data: results.map((r, i) => ({ symbol: symbols[i], ...r })),
};
},
});
Set up the AI provider
Configure the AI SDK with your model and tools.
// src/providers/ai-provider.tsx
'use client';
import { AIProvider } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { getStockData, compareStocks } from '@/lib/tools';
export function AIProviderWrapper({ children }: { children: React.ReactNode }) {
return (
<AIProvider
model={openai('gpt-4o')}
tools={{ getStockData, compareStocks }}
system={`
You are a financial charting assistant. When users ask for stock charts, use the getStockData tool to fetch data, then render a StockChart component with the results.
Guidelines:
- Always fetch data before rendering a chart
- Default to 1M timeframe if not specified
- Include relevant technical indicators (SMA 20/50, Bollinger Bands) for longer timeframes
- For comparison requests, use compareStocks tool
- Respond with the chart component, not raw data
- Keep explanations brief and focused on the chart
`.trim()}
>
{children}
</AIProvider>
);
}
Create the chart renderer
This component receives the streamed React component from the server and renders it on the client.
// src/components/chart-renderer.tsx
'use client';
import { createElement } from 'react';
import { StockChart } from './stock-chart';
import { ChartProps } from '@/lib/types';
const componentMap = {
StockChart,
} as const;
export function ChartRenderer({ component }: { component: { type: string; props: ChartProps } }) {
const Component = componentMap[component.type as keyof typeof componentMap];
if (!Component) {
return <div className="text-red-500">Unknown component: {component.type}</div>;
}
return createElement(Component, component.props);
}
Build the chat interface
The client-side chat UI handles streaming messages and rendering components.
// src/components/chat-interface.tsx
'use client';
import { useChat } from 'ai/react';
import { ChartRenderer } from './chart-renderer';
import { StockChart } from './stock-chart';
const componentRenderers = {
StockChart: ChartRenderer,
};
export function ChatInterface() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error, stop } = useChat({
api: '/api/chat',
streamProtocol: 'data',
});
return (
<div className="flex flex-col h-full w-full max-w-4xl mx-auto p-4 gap-4">
<div className="flex-1 overflow-y-auto space-y-4">
{messages.map(message => (
<div key={message.id} className={`flex gap-3 ${message.role === 'user' ? 'justify-end' : ''}`}>
<div
className={`max-w-[80%] px-4 py-2 rounded-2xl ${
message.role === 'user'
? 'bg-blue-600 text-white rounded-br-none'
: 'bg-gray-100 text-gray-900 rounded-bl-none'
}`}
>
{message.parts.map((part, i) => {
if (part.type === 'text') {
return <p key={i} className="whitespace-pre-wrap">{part.text}</p>;
}
if (part.type === 'tool-invocation' && part.toolInvocation.state === 'result') {
return (
<details key={i} className="text-xs text-gray-500 mt-2">
<summary>Tool: {part.toolInvocation.toolName}</summary>
<pre className="mt-1 p-2 bg-gray-50 rounded overflow-auto text-xs">
{JSON.stringify(part.toolInvocation.result, null, 2)}
</pre>
</details>
);
}
if (part.type === 'ui') {
return <ChartRenderer key={i} component={part.ui} />;
}
return null;
})}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 text-gray-900 px-4 py-2 rounded-2xl rounded-bl-none animate-pulse">
<div className="flex gap-1">
<span>▌</span>
</div>
</div>
</div>
)}
</div>
{error && (
<div className="text-red-500 text-sm p-3 bg-red-50 rounded-lg">
Error: {error.message}
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask for a stock chart (e.g., 'Show me AAPL 1Y with Bollinger Bands')"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
{isLoading ? (
<button
type="button"
onClick={stop}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Stop
</button>
) : (
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={!input.trim()}
>
Send
</button>
)}
</form>
<div className="text-xs text-gray-500 text-center">
Examples: "AAPL 6 months", "Compare MSFT and GOOGL 1Y", "TSLA 3M with SMA 20/50"
</div>
</div>
);
}
Create the API route
The server-side handler streams the model response with component rendering.
// src/app/api/chat/route.ts
import { createAI, streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { getStockData, compareStocks } from '@/lib/tools';
import { StockChart } from '@/components/stock-chart';
import { ChartProps } from '@/lib/types';
export const runtime = 'edge';
const ai = createAI({
model: openai('gpt-4o'),
tools: { getStockData, compareStocks },
system: `
You are a financial charting assistant. When users ask for stock charts, use the getStockData tool to fetch data, then render a StockChart component with the results.
Guidelines:
- Always fetch data before rendering a chart
- Default to 1M timeframe if not specified
- Include relevant technical indicators (SMA 20/50, Bollinger Bands) for longer timeframes
- For comparison requests, use compareStocks tool
- Respond with the chart component, not raw data
- Keep explanations brief and focused on the chart
`.trim(),
});
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamUI({
model: ai.model,
tools: ai.tools,
system: ai.system,
messages,
components: {
StockChart,
},
onToolCall: async ({ toolName, args }) => {
console.log(`Tool call: ${toolName}`, args);
},
onToolResult: async ({ toolName, result }) => {
console.log(`Tool result: ${toolName}`, result);
},
});
return result.toUIStreamResponse();
}
Wire up the main page
// src/app/page.tsx
import { AIProviderWrapper } from '@/providers/ai-provider';
import { ChatInterface } from '@/components/chat-interface';
export default function Home() {
return (
<AIProviderWrapper>
<main className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 px-4 py-4">
<h1 className="text-2xl font-bold text-gray-900">Generative Stock Charts</h1>
<p className="text-sm text-gray-500 mt-1">Ask for any stock chart — the model fetches data and renders it live</p>
</header>
<div className="flex-1 flex items-start justify-center pt-8 pb-16">
<ChatInterface />
</div>
</main>
</AIProviderWrapper>
);
}
Update the layout for proper fonts and metadata:
// src/app/layout.tsx
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export const metadata: Metadata = {
title: 'Generative Stock Charts with Vercel AI SDK',
};
export const viewport: Viewport = {
themeColor: '#ffffff',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} antialiased`}>
<body className="min-h-screen bg-gray-50 font-sans">{children}</body>
</html>
);
}
Run and test
Start the development server:
pnpm dev
Open http://localhost:3000. Try these prompts:
- “Show me AAPL for the last 6 months” — renders a 6-month chart with default indicators
- “Compare MSFT and GOOGL over 1 year” — calls compareStocks, renders side-by-side (you’ll need to extend the UI for multi-chart)
- “TSLA 3 months with SMA 20 and 50” — fetches 3M data, renders with both moving averages
- “NVDA 1Y with Bollinger Bands” — longer timeframe triggers Bollinger calculation
Expected output checkpoints
After first message (“AAPL 6M”):
- Tool call
getStockDataappears in chat with{ symbol: "AAPL", timeframe: "3M" }(6M maps to 3M range) - Tool returns success with ~60 data points
StockChartcomponent streams and renders — green area chart with price, latest close prominent
Chart interactions:
- Hover shows tooltip with date and price
- Indicators render as dashed lines (SMA) or shaded bands (Bollinger)
- Header shows symbol, timeframe, current price, and change percentage
Error handling:
- Invalid symbol → tool returns error, model apologizes and asks for valid ticker
- Network failure → error boundary catches, shows retry option
Extending the pattern
This foundation supports several natural extensions:
Multi-chart comparison: Modify ChartRenderer to accept an array of components, or create a ComparisonChart component that normalizes multiple series to percentage change.
Real-time updates: Add a useInterval hook that re-fetches the latest candle every minute during market hours and updates the chart via a server action.
Additional indicators: RSI, MACD, volume profile — add calculation functions in stock-chart.tsx and expose them through the indicators prop.
Provider fallback: If you route through a gateway like n4n.ai, swap the openai('gpt-4o') model for the gateway endpoint and gain automatic fallback when a provider degrades.
Authentication: Wrap the API route with NextAuth or Clerk, then add per-user rate limiting and usage metering.
Key takeaways
- The AI SDK’s
streamUIwithcomponentsmapping lets the model return typed React components directly — no JSON parsing, no client-side component mapping logic - Tools run on the server, so API keys stay secret and you can use
fetchwithnext: { revalidate }for caching - Recharts works in RSC because it outputs SVG — no client hydration needed for static charts
- The
useChathook handles the streaming protocol automatically;partsarray contains text, tool invocations, and UI components in order
This pattern scales to any domain where the model should render interactive visualizations: dashboards, diagrams, forms, or custom data displays. The model becomes the presentation layer, not just the reasoning layer.