Build Generative UI with Vercel AI SDK: Stream React Components from an LLM

Monday 25/05/2026

·10 min read
Share:

You've built a chatbot. It streams markdown beautifully. Then a designer asks: "Can it show an actual interactive flight card instead of a bullet list?" Welcome to generative UI with Vercel AI SDK — the pattern where the LLM doesn't just stream text, it streams which React components to render, with what props, in what order. Done right, you get a flight-booking assistant where the model says "Found three options" and a real <FlightCard /> appears mid-message, complete with selectable rows and a date picker.

This is different from the streaming markdown problem I covered in streaming AI UX in React. Markdown rendering is a transformation of text. Generative UI is the LLM choosing tools whose return values map to typed, interactive React components with state and event handlers. It's also different from the human-in-the-loop pattern — that's about approvals; this is about rendering.

The mental model: tools are components

Vercel's generative UI is built on a simple idea: a tool the LLM calls doesn't have to return a string. It can return structured data that maps 1:1 to a React component on the client. The server defines the tool with a Zod schema. The client maintains a registry: toolName → ReactComponent. The AI SDK streams tool-invocation parts into your message stream, and you render them.

The crucial detail most tutorials skip: each tool invocation goes through three states — partial-call (args still streaming), call (args complete, executing), result (done). Your UI needs to render something at each state, or you get a janky empty space until the execute finishes.

Install and set up

pnpm add ai @ai-sdk/openai @ai-sdk/react zod

I'm using OpenAI here, but swap in @ai-sdk/anthropic if you want Claude — generative UI works across providers because the abstraction is at the AI SDK layer, not the model.

Define tools as UI contracts

The trick is treating tool schemas as the prop type for your component. If your FlightCard takes a list of flights, your tool returns exactly that shape. Zod gives you both runtime validation and a TypeScript type to reuse on the client.

// src/app/api/chat/tools.ts
import { z } from 'zod'

export const flightSchema = z.object({
    id: z.string(),
    airline: z.string(),
    from: z.string(),
    to: z.string(),
    departure: z.string(),
    arrival: z.string(),
    priceUsd: z.number(),
    stops: z.number(),
})

export const showFlightsResultSchema = z.object({
    query: z.object({
        origin: z.string(),
        destination: z.string(),
        date: z.string(),
    }),
    flights: z.array(flightSchema),
})

export const datePickerResultSchema = z.object({
    prompt: z.string(),
    earliest: z.string(),
    latest: z.string(),
})

export const confirmBookingResultSchema = z.object({
    flightId: z.string(),
    passengerName: z.string(),
    priceUsd: z.number(),
})

export type Flight = z.infer<typeof flightSchema>
export type ShowFlightsResult = z.infer<typeof showFlightsResultSchema>
export type DatePickerResult = z.infer<typeof datePickerResultSchema>
export type ConfirmBookingResult = z.infer<typeof confirmBookingResultSchema>

The server route: streamText with UI-returning tools

The API route looks identical to a normal AI SDK chat handler — the magic is in what execute returns. Each tool resolves to data that the client will use to render a component.

// src/app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import { streamText, tool } from 'ai'
import { z } from 'zod'
import {
    confirmBookingResultSchema,
    datePickerResultSchema,
    showFlightsResultSchema,
} from './tools'

export const maxDuration = 30

const SYSTEM_PROMPT = `You are a flight booking assistant. When the user wants to search for flights, call showFlights.
If they're vague about dates, call datePicker first to clarify. When they pick a flight, call confirmBooking.
Always speak naturally between tool calls — don't narrate the tool calls themselves.`

export async function POST(req: Request) {
    const { messages } = await req.json()

    const result = streamText({
        model: openai('gpt-4o'),
        system: SYSTEM_PROMPT,
        messages,
        tools: {
            showFlights: tool({
                description: 'Search and display flight options as interactive cards.',
                parameters: z.object({
                    origin: z.string().describe('IATA code, e.g. JFK'),
                    destination: z.string().describe('IATA code, e.g. LHR'),
                    date: z.string().describe('YYYY-MM-DD'),
                }),
                execute: async ({ origin, destination, date }) => {
                    const flights = await searchFlights({ origin, destination, date })
                    return showFlightsResultSchema.parse({
                        query: { origin, destination, date },
                        flights,
                    })
                },
            }),
            datePicker: tool({
                description: 'Ask the user to pick a date when their request is ambiguous.',
                parameters: z.object({
                    prompt: z.string(),
                    earliest: z.string(),
                    latest: z.string(),
                }),
                execute: async (args) => datePickerResultSchema.parse(args),
            }),
            confirmBooking: tool({
                description: 'Show a confirmation dialog before booking a flight.',
                parameters: z.object({
                    flightId: z.string(),
                    passengerName: z.string(),
                    priceUsd: z.number(),
                }),
                execute: async (args) => confirmBookingResultSchema.parse(args),
            }),
        },
        maxSteps: 5,
    })

    return result.toDataStreamResponse()
}

async function searchFlights(query: {
    origin: string
    destination: string
    date: string
}) {
    // Replace with your real flight provider (Amadeus, Duffel, Skyscanner, etc.)
    return [
        {
            id: 'BA117',
            airline: 'British Airways',
            from: query.origin,
            to: query.destination,
            departure: `${query.date}T08:30`,
            arrival: `${query.date}T20:15`,
            priceUsd: 612,
            stops: 0,
        },
        {
            id: 'AA101',
            airline: 'American',
            from: query.origin,
            to: query.destination,
            departure: `${query.date}T10:45`,
            arrival: `${query.date}T22:30`,
            priceUsd: 548,
            stops: 0,
        },
    ]
}

Notice maxSteps: 5. Without it, the model can't continue talking after a tool result — it'll just stop. With it, the model sees the tool result and can naturally continue: "Here are three options. Want me to book the cheapest?" That's what makes generative UI feel conversational instead of robotic.

The datePicker tool is interesting — it doesn't do anything server-side, it just echoes its args back. The "execution" is the UI rendering. The user picking a date is the real work, and that happens on the client.

The component registry

This is the only architectural piece beyond a normal chat app. The registry maps tool names to components. Keeping it as a typed object gives you compile-time safety: if you rename a tool on the server, TypeScript flags every component using the old key.

// src/components/generative/registry.tsx
import { FlightCard } from './FlightCard'
import { DatePickerCard } from './DatePickerCard'
import { ConfirmBookingDialog } from './ConfirmBookingDialog'

export const componentRegistry = {
    showFlights: FlightCard,
    datePicker: DatePickerCard,
    confirmBooking: ConfirmBookingDialog,
} as const

export type ToolName = keyof typeof componentRegistry

Rendering tool invocations with state-aware fallbacks

The chat component reads each message's parts. For a tool-invocation part, it picks the component and renders the right thing for the current state. The partial-call state is the one most tutorials skip — that's the few hundred milliseconds while the model is still streaming the tool's arguments. Show a skeleton there, not nothing.

// src/components/generative/Chat.tsx
'use client'
import { useChat } from '@ai-sdk/react'
import { componentRegistry } from './registry'
import { FallbackTool } from './FallbackTool'
import { ToolSkeleton } from './ToolSkeleton'

export function Chat() {
    const { messages, input, handleInputChange, handleSubmit, addToolResult } = useChat({
        api: '/api/chat',
    })

    return (
        <div className="flex flex-col gap-4 max-w-2xl mx-auto p-4">
            {messages.map((message) => (
                <div key={message.id} className="space-y-2">
                    {message.parts?.map((part, i) => {
                        if (part.type === 'text') {
                            return (
                                <div key={i} className="prose">
                                    {part.text}
                                </div>
                            )
                        }
                        if (part.type === 'tool-invocation') {
                            const { toolName, state, toolCallId } = part.toolInvocation
                            const Component = componentRegistry[toolName as keyof typeof componentRegistry]

                            if (!Component) {
                                return <FallbackTool key={toolCallId} name={toolName} state={state} />
                            }
                            if (state === 'partial-call' || state === 'call') {
                                return <ToolSkeleton key={toolCallId} name={toolName} />
                            }
                            if (state === 'result') {
                                return (
                                    <Component
                                        key={toolCallId}
                                        {...(part.toolInvocation.result as never)}
                                        onAction={(result: unknown) =>
                                            addToolResult({ toolCallId, result })
                                        }
                                    />
                                )
                            }
                        }
                        return null
                    })}
                </div>
            ))}
            <form onSubmit={handleSubmit} className="flex gap-2">
                <input
                    value={input}
                    onChange={handleInputChange}
                    placeholder="Find me a flight..."
                    className="flex-1 rounded border px-3 py-2"
                />
                <button type="submit" className="rounded bg-black px-4 py-2 text-white">
                    Send
                </button>
            </form>
        </div>
    )
}

A real component: FlightCard

The component receives the tool result as props — exactly the shape the server returned. Because the type comes from the Zod schema, props are fully typed without manual definitions.

// src/components/generative/FlightCard.tsx
'use client'
import { ShowFlightsResult } from '@/app/api/chat/tools'

type Props = ShowFlightsResult & {
    onAction: (result: { selectedFlightId: string }) => void
}

export function FlightCard({ query, flights, onAction }: Props) {
    return (
        <div className="rounded-xl border bg-white p-4 shadow-sm">
            <div className="mb-3 text-sm text-gray-500">
                {query.origin} → {query.destination} · {query.date}
            </div>
            <ul className="divide-y">
                {flights.map((f) => (
                    <li key={f.id} className="flex items-center justify-between py-3">
                        <div>
                            <div className="font-medium">{f.airline}</div>
                            <div className="text-sm text-gray-500">
                                {new Date(f.departure).toLocaleTimeString()} →{' '}
                                {new Date(f.arrival).toLocaleTimeString()}
                                {f.stops === 0 ? ' · Nonstop' : ` · ${f.stops} stop(s)`}
                            </div>
                        </div>
                        <div className="text-right">
                            <div className="font-semibold">${f.priceUsd}</div>
                            <button
                                onClick={() => onAction({ selectedFlightId: f.id })}
                                className="text-sm text-blue-600 hover:underline"
                            >
                                Select
                            </button>
                        </div>
                    </li>
                ))}
            </ul>
        </div>
    )
}

When the user clicks Select, addToolResult sends the selection back through useChat. The model sees the user's choice as a tool result and continues — typically calling confirmBooking next.

Skeleton and fallback components

These are small but they make the difference between a "demo" and a shipped feature.

// src/components/generative/ToolSkeleton.tsx
export function ToolSkeleton({ name }: { name: string }) {
    return (
        <div className="rounded-xl border bg-white p-4 shadow-sm" aria-busy="true">
            <div className="mb-2 h-3 w-32 animate-pulse rounded bg-gray-200" />
            <div className="space-y-2">
                {[1, 2].map((i) => (
                    <div key={i} className="flex justify-between">
                        <div className="h-4 w-40 animate-pulse rounded bg-gray-200" />
                        <div className="h-4 w-12 animate-pulse rounded bg-gray-200" />
                    </div>
                ))}
            </div>
            <div className="sr-only">Loading {name}...</div>
        </div>
    )
}
// src/components/generative/FallbackTool.tsx
export function FallbackTool({ name, state }: { name: string; state: string }) {
    return (
        <div className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
            The assistant tried to use <code>{name}</code> ({state}), but no UI is registered
            for it. Showing raw fallback so the conversation can continue.
        </div>
    )
}

The fallback is critical for resilience. If the model invents a tool name (it shouldn't, but gpt-4o occasionally does on long conversations) or you deploy a server with a new tool before the client knows about it, the UI degrades gracefully instead of crashing.

Gotchas I hit

A few things that wasted my time so they don't have to waste yours:

  • maxSteps defaults to 1. Without it, the model calls one tool and stops. Set it to at least 3 for any real flow.
  • Don't JSON.stringify tool results into text. Return objects, not strings. The serialization happens for you, and stringifying breaks the typed prop contract.
  • Component keys must use toolCallId, not index. During streaming, parts re-render and reusing array indices causes React to swap state between unrelated components.
  • Server actions in tool results don't work mid-stream. If you need the LLM to trigger a write, do it in execute. If you need the user to trigger it, do it via onActionaddToolResult.
  • The partial-call state args are partial JSON. Don't try to parse them as full args. Use them for skeletons that don't need full props.

When generative UI is the wrong call

Don't reach for this if your "UI" is just a styled list of strings — markdown rendering is simpler and the streaming UX is better. Generative UI shines when components have interactive state (selectable rows, form fields, confirmation flows) or when you're composing multiple distinct components into one assistant turn. If you're choosing between frameworks for an agent-first product, my Vercel AI SDK vs Mastra vs LangChain.js comparison covers the tradeoffs in depth — generative UI is one of the strongest reasons to pick Vercel's SDK.

What's next

Once you have components streaming, the next problem is image generation — sometimes the right component to render is an AI-generated image, and that introduces a whole new set of async/storage concerns. I'll walk through that pipeline in adding AI image generation with Replicate, Fal, and Cloudflare R2 next.

Share:
VA

Vadim Alakhverdov

Software developer writing about JavaScript, web development, and developer tools.

Related Posts