opncrafter
Module 6 of 10: Generative UI

Building Generative Charts

Building Generative Charts

Jan 3, 2026 • 20 min read

Library: Recharts + Vercel AI SDK + RSC

One of the "killer apps" of Generative UI is dynamic data visualization: users ask "Compare Apple and Microsoft revenue for the last 5 years" and get an interactive chart instantly. The critical architectural principle is the strict separation of LLM intent (which chart to show, which data to fetch) from real data fetching (your APIs, databases). Never let the LLM generate the numbers — it will hallucinate with complete confidence.

1. The Architecture: LLM as Chart Director, Not Data Source

❌ WRONG — Never do this:
"Generate a chart showing Apple's stock price over 2024"
The LLM will invent convincing but fabricated stock price data. There is no safe way to prompt your way around this.
✅ CORRECT pipeline:
  1. User asks: "Show me Apple stock for the past year"
  2. LLM decides: call get_stock_history(symbol="AAPL", period="1y")
  3. Your code: fetches real data from Yahoo Finance API
  4. Your code: returns <StockChart data={realData} /> to client

2. Tool Definition and Server Action (Vercel AI SDK + RSC)

// actions.tsx
'use server';
import { createAI, streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import { BotCard, StockChart, StockSkeleton, MultiLineChart } from '@/components/charts';

export async function submitUserMessage(userInput: string) {
    const ui = await streamUI({
        model: openai('gpt-4o'),
        system: `You are a financial data assistant. 
When users ask about stocks, crypto, or financial data, use the available tools to fetch 
real data and display charts. Never make up financial numbers.`,
        messages: [{ role: 'user', content: userInput }],
        
        tools: {
            // Single stock chart
            show_stock_price: {
                description: 'Display an interactive stock price chart for a single ticker symbol',
                parameters: z.object({
                    symbol: z.string().describe('The stock ticker symbol (e.g., AAPL, MSFT, GOOGL)'),
                    period: z.enum(['1d', '5d', '1mo', '3mo', '1y', '5y'])
                           .default('1y')
                           .describe('Time period for the chart'),
                }),
                generate: async function* ({ symbol, period }) {
                    // Show skeleton immediately — zero perceived latency
                    yield (
                        <BotCard>
                            <StockSkeleton symbol={symbol} />
                        </BotCard>
                    );

                    try {
                        // Fetch real data from your API layer
                        const stockData = await fetchStockData(symbol, period);
                        
                        return (
                            <BotCard>
                                <StockChart
                                    data={stockData.prices}    // [{date, open, close, volume}]
                                    symbol={symbol}
                                    currentPrice={stockData.currentPrice}
                                    change24h={stockData.change24h}
                                    period={period}
                                />
                            </BotCard>
                        );
                    } catch (error) {
                        return (
                            <BotCard>
                                <div className="text-red-400">
                                    Failed to load data for {symbol}. 
                                    The symbol may be invalid or the market may be closed.
                                </div>
                            </BotCard>
                        );
                    }
                },
            },

            // Multi-stock comparison chart
            compare_stocks: {
                description: 'Compare multiple stocks on the same chart for performance comparison',
                parameters: z.object({
                    symbols: z.array(z.string())
                              .min(2)
                              .max(5)
                              .describe('Array of ticker symbols to compare'),
                    period: z.enum(['1mo', '3mo', '6mo', '1y', '5y']).default('1y'),
                    normalize: z.boolean()
                               .default(true)
                               .describe('Normalize to percentage change from start date for fair comparison'),
                }),
                generate: async function* ({ symbols, period, normalize }) {
                    yield (
                        <BotCard>
                            <div style={{ display: 'flex', gap: '0.5rem' }}>
                                {symbols.map(sym => <StockSkeleton key={sym} symbol={sym} compact />)}
                            </div>
                        </BotCard>
                    );

                    // Fetch all in parallel — much faster than sequential
                    const allData = await Promise.all(
                        symbols.map(sym => fetchStockData(sym, period))
                    );

                    // Merge datasets by date for Recharts
                    const mergedData = mergeByDate(
                        allData.map((d, i) => ({ symbol: symbols[i], prices: d.prices })),
                        normalize  // If true, convert to % change from first data point
                    );

                    return (
                        <BotCard>
                            <MultiLineChart
                                data={mergedData}
                                lines={symbols}
                                yAxisLabel={normalize ? '% Return' : 'Price ($)'}
                            />
                        </BotCard>
                    );
                },
            },
        },

        text: ({ content, done }) => (
            <BotCard>{content}</BotCard>
        ),
    });

    return { display: ui.value };
}

3. The Recharts Component Layer

// components/charts/StockChart.tsx
'use client';
import {
    AreaChart, Area, XAxis, YAxis, CartesianGrid,
    Tooltip, ResponsiveContainer, ReferenceLine
} from 'recharts';
import { format } from 'date-fns';

interface StockDataPoint {
    date: string;     // ISO date string
    close: number;
    volume: number;
}

interface StockChartProps {
    data: StockDataPoint[];
    symbol: string;
    currentPrice: number;
    change24h: number;  // Percentage change
    period: string;
}

// Custom tooltip for financial data
const CustomTooltip = ({ active, payload, label }: any) => {
    if (!active || !payload?.length) return null;
    return (
        <div style={{ background: '#1e1e2e', border: '1px solid rgba(255,255,255,0.1)', padding: '0.75rem', borderRadius: '8px' }}>
            <p style={{ color: 'var(--text-secondary)', fontSize: '0.8rem' }}>{label}</p>
            <p style={{ fontWeight: 'bold', color: '#a3e635' }}>
                ${payload[0].value.toFixed(2)}
            </p>
        </div>
    );
};

export function StockChart({ data, symbol, currentPrice, change24h, period }: StockChartProps) {
    const isPositive = change24h >= 0;
    const color = isPositive ? '#22c55e' : '#ef4444';
    
    // Format X-axis labels based on period
    const formatXAxis = (dateStr: string) => {
        const date = new Date(dateStr);
        if (period === '1d') return format(date, 'HH:mm');
        if (period === '5d') return format(date, 'EEE');
        if (period === '1mo') return format(date, 'MMM d');
        return format(date, 'MMM yy');
    };

    return (
        <div style={{ padding: '1rem' }}>
            {/* Header with price and change */}
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
                <div>
                    <h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 'bold' }}>{symbol}</h3>
                    <span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{period} chart</span>
                </div>
                <div style={{ textAlign: 'right' }}>
                    <div style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>${currentPrice.toFixed(2)}</div>
                    <div style={{ color, fontSize: '0.85rem' }}>
                        {isPositive ? '+' : ''}{change24h.toFixed(2)}%
                    </div>
                </div>
            </div>

            {/* Recharts Area Chart */}
            <div style={{ height: '250px' }}>
                <ResponsiveContainer width="100%" height="100%">
                    <AreaChart data={data} margin={{ top: 5, right: 5, left: -10, bottom: 5 }}>
                        <defs>
                            <linearGradient id={"gradient-" + symbol} x1="0" y1="0" x2="0" y2="1">
                        <stop offset="5%" stopColor={color} stopOpacity={0.4} />
                        <stop offset="95%" stopColor={color} stopOpacity={0} />
                    </linearGradient>
                </defs>
                <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
                <XAxis dataKey="date" tickFormatter={formatXAxis} tick={{ fontSize: 10, fill: '#888' }} />
                <YAxis domain={['auto', 'auto']} tick={{ fontSize: 10, fill: '#888' }} />
                <Tooltip content={<CustomTooltip />} />
                <Area
                    type="monotone"
                    dataKey="close"
                    stroke={color}
                    strokeWidth={2}
                    fill={"url(#gradient-" + symbol + ")"}
                        />
            </AreaChart>
        </ResponsiveContainer>
            </div >
        </div >
    );
}

Frequently Asked Questions

How do I handle tool calls for non-financial data sources?

The same pattern applies to any data: replace the stock API call with your database query, REST API, or GraphQL endpoint. Define specific tools for each data source: show_sales_chart(metric, date_range), show_user_activity(cohort), show_inventory_levels(category). The LLM reasons about which tool to call based on the descriptions you provide — write tool descriptions as if explaining to a knowledgeable analyst what data is available. The more precise your tool descriptions and parameter types (using Zod schemas), the better the LLM's tool selection accuracy.

How do I prevent users from requesting sensitive data via the chart tool?

Authorization must be enforced at the data layer, not the prompt level. Before calling your data API in the generate function, verify the authenticated user has access to the requested data: check if the user has permissions to view the requested symbol/dataset, filter API responses to only return data the user is authorized to see, and log all data access for audit purposes. Never rely on the system prompt alone to prevent access — an adversarial user can bypass prompt-level restrictions through creative phrasing.

Conclusion

Generative charts deliver the "wow" factor that makes AI dashboards genuinely impressive: natural language queries that return interactive, real-data visualizations instantly. The architectural discipline is non-negotiable: LLMs decide what chart to show and call the appropriate tool; your server code fetches real data and returns polished Recharts components. This separation ensures accuracy (no hallucinated numbers), security (authorization enforced in your server code), and extensibility (add new chart types by defining new tools). The skeleton-while-loading pattern provides instant visual feedback, making even slow API calls feel responsive.

👨‍💻
Written by

Vivek

AI Engineer

Full-stack AI engineer with 4+ years building LLM-powered products, autonomous agents, and RAG pipelines. I've shipped AI features to production for startups and worked hands-on with GPT-4o, LangChain, LlamaIndex, and the Vercel AI SDK. I started OpnCrafter to share everything I wish I had when learning — no fluff, just working code and real-world context.

GPT-4oLangChainNext.jsVector DBsRAGVercel AI SDK

Continue Reading

👨‍💻
Written by

Vivek

AI Engineer

Full-stack AI engineer with 4+ years building LLM-powered products, autonomous agents, and RAG pipelines. I've shipped AI features to production for startups and worked hands-on with GPT-4o, LangChain, LlamaIndex, and the Vercel AI SDK. I started OpnCrafter to share everything I wish I had when learning — no fluff, just working code and real-world context.

GPT-4oLangChainNext.jsVector DBsRAGVercel AI SDK