n4nAI

Building a generative stock chart UI with Vercel AI SDK

Build a generative stock chart UI with Vercel AI SDK using React Server Components, streaming chart components directly from the model.

n4n Team3 min read685 words

Audio narration

Coming soon — every post will get a voice note here.

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:

  1. “Show me AAPL for the last 6 months” — renders a 6-month chart with default indicators
  2. “Compare MSFT and GOOGL over 1 year” — calls compareStocks, renders side-by-side (you’ll need to extend the UI for multi-chart)
  3. “TSLA 3 months with SMA 20 and 50” — fetches 3M data, renders with both moving averages
  4. “NVDA 1Y with Bollinger Bands” — longer timeframe triggers Bollinger calculation

Expected output checkpoints

After first message (“AAPL 6M”):

  • Tool call getStockData appears in chat with { symbol: "AAPL", timeframe: "3M" } (6M maps to 3M range)
  • Tool returns success with ~60 data points
  • StockChart component 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 streamUI with components mapping 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 fetch with next: { revalidate } for caching
  • Recharts works in RSC because it outputs SVG — no client hydration needed for static charts
  • The useChat hook handles the streaming protocol automatically; parts array 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.

Tagsvercel-ai-sdkgenerative-uistock-charttutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vercel ai sdk generative ui with react server components posts →