The Vercel AI SDK’s streamUI function lets you stream React components directly from tool calls, turning LLM outputs into interactive UI without manual parsing. This approach — rendering tool results as React components with streamUI — shifts the rendering logic to the server while keeping client interactivity intact. Below is a complete, runnable walkthrough using Next.js App Router, React Server Components, and the AI SDK’s streamUI API.
Step 1: Set up the project and dependencies
Create a new Next.js project with the App Router and install the AI SDK packages. You need ai for the core streaming logic and @ai-sdk/react for the client hooks.
npx create-next-app@latest streamui-demo --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd streamui-demo
npm install ai @ai-sdk/react @ai-sdk/openai zod
Verify the install works by running npm run dev and confirming the default page loads at http://localhost:3000.
Step 2: Define the tool schema and component map
streamUI requires a tools object where each tool returns a React component instead of raw data. The component map tells the SDK which component to render for each tool result. Create lib/tools.ts to keep types shared between server and client.
// lib/tools.ts
import { z } from "zod";
import type { UIMessage } from "ai";
export const weatherTool = {
parameters: z.object({
location: z.string().describe("City and state, e.g. San Francisco, CA"),
unit: z.enum(["celsius", "fahrenheit"]).default("fahrenheit"),
}),
// The generate function returns a React component, not data
generate: async function* ({ location, unit }: { location: string; unit: "celsius" | "fahrenheit" }) {
// Simulate an API call — replace with real provider
const tempF = Math.floor(Math.random() * 30) + 50;
const tempC = Math.round((tempF - 32) * 5 / 9);
const temp = unit === "celsius" ? `${tempC}°C` : `${tempF}°F`;
const condition = ["Sunny", "Cloudy", "Rainy", "Partly cloudy"][Math.floor(Math.random() * 4)];
yield (
<WeatherCard
location={location}
temperature={temp}
condition={condition}
unit={unit}
/>
);
},
};
export const stockTool = {
parameters: z.object({
symbol: z.string().describe("Stock ticker symbol, e.g. AAPL"),
}),
generate: async function* ({ symbol }: { symbol: string }) {
const price = (Math.random() * 500 + 50).toFixed(2);
const change = (Math.random() * 10 - 5).toFixed(2);
const changePercent = ((parseFloat(change) / (parseFloat(price) - parseFloat(change))) * 100).toFixed(2);
yield (
<StockCard
symbol={symbol.toUpperCase()}
price={parseFloat(price)}
change={parseFloat(change)}
changePercent={parseFloat(changePercent)}
/>
);
},
};
export const tools = { weather: weatherTool, stock: stockTool };
// Component props types for client-side rendering
export interface WeatherCardProps {
location: string;
temperature: string;
condition: string;
unit: "celsius" | "fahrenheit";
}
export interface StockCardProps {
symbol: string;
price: number;
change: number;
changePercent: number;
}
The generate functions are async generators that yield JSX. This is the core of the streamui tool results react components pattern — the tool is the component renderer.
Step 3: Create the React components
Build the actual components that the tools will render. These live in components/ui and must be client components because they may contain interactivity (buttons, charts, etc.).
// components/ui/WeatherCard.tsx
"use client";
import type { WeatherCardProps } from "@/lib/tools";
export function WeatherCard({ location, temperature, condition, unit }: WeatherCardProps) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-800">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{location}</h3>
<span className="text-2xl font-mono font-bold text-slate-900 dark:text-slate-100">{temperature}</span>
</div>
<p className="mt-1 text-slate-600 dark:text-slate-400 capitalize">{condition}</p>
<div className="mt-3 flex items-center gap-2">
<button
className="text-xs px-2 py-1 rounded bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-200"
onClick={() => navigator.clipboard.writeText(`${location}: ${temperature}, ${condition}`)}
>
Copy
</button>
<span className="text-xs text-slate-500">Unit: {unit}</span>
</div>
</div>
);
}
// components/ui/StockCard.tsx
"use client";
import type { StockCardProps } from "@/lib/tools";
export function StockCard({ symbol, price, change, changePercent }: StockCardProps) {
const isPositive = change >= 0;
return (
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-800">
<div className="flex items-baseline justify-between">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{symbol}</h3>
<span className="text-2xl font-mono font-bold text-slate-900 dark:text-slate-100">${price.toFixed(2)}</span>
</div>
<div className="mt-1 flex items-center gap-2">
<span className={`text-sm font-mono ${isPositive ? "text-green-600" : "text-red-600"}`}>
{isPositive ? "+" : ""}{change.toFixed(2)} ({isPositive ? "+" : ""}{changePercent}%)
</span>
<span className="text-xs text-slate-500">Real-time quote</span>
</div>
<div className="mt-3 flex gap-2">
<button className="flex-1 text-xs px-2 py-1 rounded bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-200">
Add to watchlist
</button>
<button className="flex-1 text-xs px-2 py-1 rounded bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-200">
View chart
</button>
</div>
</div>
);
}
Step 4: Build the server action that streams UI
The server action calls streamUI from ai and returns a ReadableStream of React Server Component payloads. Create app/actions/chat.ts.
// app/actions/chat.ts
"use server";
import { streamUI } from "ai";
import { openai } from "@ai-sdk/openai";
import { tools } from "@/lib/tools";
import { WeatherCard, StockCard } from "@/components/ui";
export async function streamChat(messages: Array<{ role: string; content: string }>) {
const result = await streamUI({
model: openai("gpt-4o"),
system: "You are a helpful assistant with access to weather and stock tools. Use them when users ask for current conditions or prices.",
messages: messages.map((m) => ({ role: m.role as "user" | "assistant", content: m.content })),
tools,
// Map tool names to actual React components for server-side rendering
components: {
weather: WeatherCard,
stock: StockCard,
},
});
// Return the stream directly — Next.js will stream the RSC payload to the client
return result.value;
}
Key points: the components object maps tool keys to the server versions of your components. These must be the same components used on the client, but imported in a server context. Because the components are marked "use client", Next.js handles the boundary automatically — the server streams the serialized component tree, and the client hydrates it.
Step 5: Create the chat route handler
For a complete streaming experience, expose the server action via a route handler that accepts POST requests. This lets you call it from the client with fetch or the useChat hook. Create app/api/chat/route.ts.
// app/api/chat/route.ts
import { streamUI } from "ai";
import { openai } from "@ai-sdk/openai";
import { tools } from "@/lib/tools";
import { WeatherCard, StockCard } from "@/components/ui";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamUI({
model: openai("gpt-4o"),
system: "You are a helpful assistant with access to weather and stock tools. Use them when users ask for current conditions or prices.",
messages,
tools,
components: {
weather: WeatherCard,
stock: StockCard,
},
});
// Return a streaming response the client can consume
return new Response(result.value, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
});
}
Step 6: Build the client chat interface
Now wire up the frontend. The useChat hook from @ai-sdk/react handles the message state and streaming. Create components/ChatInterface.tsx.
// components/ChatInterface.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { useState, FormEvent } from "react";
export function ChatInterface() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: "/api/chat",
// The hook expects the stream to contain UI messages with component payloads
// No extra config needed — streamUI output matches the UIMessage format
});
const [localInput, setLocalInput] = useState(input);
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!localInput.trim() || isLoading) return;
handleSubmit(new FormData(e.currentTarget));
setLocalInput("");
};
return (
<div className="flex flex-col h-[600px] w-full max-w-2xl mx-auto border border-slate-200 rounded-xl overflow-hidden dark:border-slate-700">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message, i) => (
<div key={i} className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] 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 dark:bg-slate-800 dark:text-slate-100"
}`}
>
{message.content}
{/* Tool results arrive as React components in the parts array */}
{message.parts?.map((part: any, partIndex: number) => (
part.type === "ui" && (
<div key={partIndex} className="mt-2">{part.element}</div>
)
))}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-slate-100 text-slate-900 rounded-2xl px-4 py-2 rounded-bl-none dark:bg-slate-800 dark:text-slate-100">
<span className="inline-block animate-pulse">▌</span>
</div>
</div>
)}
{error && <div className="text-red-600 text-sm p-4">Error: {error.message}</div>}
</div>
<form onSubmit={onSubmit} className="border-t border-slate-200 p-4 dark:border-slate-700">
<div className="flex gap-2">
<input
type="text"
value={localInput}
onChange={(e) => setLocalInput(e.target.value)}
placeholder="Ask about weather or stock prices..."
className="flex-1 rounded-lg border border-slate-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !localInput.trim()}
className="px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</form>
</div>
);
}
The critical line is {part.element} — streamUI emits UI message parts where element is the actual React component (already hydrated). The useChat hook handles the stream parsing automatically when the response comes from a streamUI endpoint.
Step 7: Wire it into a page
Replace the default page with your chat interface. Edit app/page.tsx.
// app/page.tsx
import { ChatInterface } from "@/components/ChatInterface";
export default function Home() {
return (
<main className="min-h-screen bg-slate-50 dark:bg-slate-950 py-12 px-4">
<div className="max-w-3xl mx-auto">
<header className="mb-8 text-center">
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">streamUI Demo</h1>
<p className="mt-2 text-slate-600 dark:text-slate-400">
Ask for weather or stock prices — tools render as React components
</p>
</header>
<ChatInterface />
</div>
</main>
);
}
Step 8: Verify the streamUI tool results render as React components
Start the dev server and test the flow end to end.
npm run dev
Open http://localhost:3000 and try these prompts:
- “What’s the weather in Seattle?” — The model calls the weather tool, and a
WeatherCardcomponent appears in the chat with live temperature, condition, and a working Copy button. - “Show me AAPL stock price” — A
StockCardrenders with price, change, and interactive buttons. - “Weather in Tokyo and AAPL stock” — Both tools fire in parallel; both components stream in as they resolve.
Success criteria:
- Components appear inline in the chat stream, not as raw JSON
- Client-side interactivity works (buttons respond, no hydration errors)
- No console errors about mismatched server/client trees
- Streaming is progressive — you see the component shell before data fills in
Step 9: Handle errors and loading states in components
Real tools fail. Update your tool generators to yield error components gracefully. Modify lib/tools.ts:
// lib/tools.ts (updated generate functions)
import { ToolErrorCard } from "@/components/ui/ToolErrorCard"; // create this component
export const weatherTool = {
// ... schema unchanged
generate: async function* ({ location, unit }: { location: string; unit: "celsius" | "fahrenheit" }) {
try {
// Real API call here
const response = await fetch(`/api/weather?location=${encodeURIComponent(location)}&unit=${unit}`);
if (!response.ok) throw new Error("Weather service unavailable");
const data = await response.json();
yield <WeatherCard location={location} temperature={data.temp} condition={data.condition} unit={unit} />;
} catch (err) {
yield <ToolErrorCard tool="weather" message={err instanceof Error ? err.message : "Unknown error"} />;
}
},
};
Create components/ui/ToolErrorCard.tsx:
// components/ui/ToolErrorCard.tsx
"use client";
export function ToolErrorCard({ tool, message }: { tool: string; message: string }) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950">
<div className="flex items-center gap-2 text-red-700 dark:text-red-300">
<svg className="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"/></svg>
<span className="font-medium">{tool} tool failed</span>
</div>
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{message}</p>
</div>
);
}
This keeps the streamui tool results react components pattern consistent — errors are components too, not special cases.
Step 10: Add typing for the UI message stream
TypeScript needs to know that message.parts can contain UI elements. Create a shared type file lib/types.ts:
// lib/types.ts
import type { UIMessage } from "ai";
import type { WeatherCardProps, StockCardProps } from "./tools";
export type ToolComponentProps = WeatherCardProps | StockCardProps;
export interface StreamUIMessage extends UIMessage<ToolComponentProps> {
parts: Array<
| { type: "text"; text: string }
| { type: "ui"; element: React.ReactElement<ToolComponentProps> }
>;
}
Then update ChatInterface.tsx to use the typed message:
// In ChatInterface.tsx
import type { StreamUIMessage } from "@/lib/types";
// ...
const { messages, ... } = useChat<StreamUIMessage>({ api: "/api/chat" });
// ...
{message.parts?.map((part, partIndex) => (
part.type === "ui" && <div key={partIndex}>{part.element}</div>
))}
This gives you full type safety across the server/client boundary — the component props are validated at compile time.
Common pitfalls and fixes
Hydration mismatch on component props
Ensure the component imported in app/actions/chat.ts (server) and components/ChatInterface.tsx (client) are the exact same file. Do not create duplicate component files. The "use client" directive at the top of the component file is what makes Next.js treat it as a client component on both sides.
Stream closes before components render
streamUI returns a promise that resolves to the stream. In the route handler, return result.value directly — do not await it. The stream is the response body.
Tools not triggering
Check that your tool descriptions are specific enough for the model to choose them. "Get current weather" is better than "Weather tool". Also verify the model supports tool calling (gpt-4o, claude-3-5-sonnet, etc.).
TypeScript complains about JSX in tool generate
Add "jsx": "preserve" and "jsxImportSource": "react" to your tsconfig.json if not already present. The AI SDK expects JSX output from generate.
Scaling: when to move tool logic to a gateway
As you add more tools — database queries, external APIs, cached lookups — the server action grows. A pattern that works well: keep the streamUI call in your Next.js route, but delegate tool execution to a dedicated inference gateway that handles provider fallback, rate limiting, and usage metering. n4n.ai exposes an OpenAI-compatible endpoint that addresses 240+ models and forwards provider cache-control hints, so your streamUI call stays unchanged while the underlying model routing becomes configurable. This keeps your component rendering logic clean and your model strategy flexible.
Next steps
- Add streaming markdown for text responses alongside components using
streamTextin parallel - Implement tool confirmation dialogs by yielding a
ConfirmationCardcomponent before the real tool runs - Cache tool results with React’s
useMemoon the client for instant re-renders on scroll - Explore
streamUI’sonToolCallandonToolResultcallbacks for analytics
The streamui tool results react components pattern eliminates the manual JSON-to-UI mapping layer. Your tools return React components directly, the SDK streams them as RSC payloads, and the client hydrates them — all with end-to-end type safety.