The vercel ai sdk streamui gpt-4o components pattern lets you turn a model’s tool call into a rendered React Server Component without building a custom orchestration layer. In this tutorial we build a minimal Next.js app that streams a weather card component directly from GPT-4o’s response. You’ll see the exact server action, client hooks, and the markup that hits the browser.
Prerequisites
- Node.js 18.17+ and a package manager (pnpm used here)
- Next.js 14 with the App Router and TypeScript
- An OpenAI API key, or any OpenAI-compatible endpoint
- Working knowledge of React Server Components and server actions
If you route through a gateway, the only change is the base URL. For example, n4n.ai exposes one OpenAI-compatible endpoint that addresses 240+ models; point @ai-sdk/openai at it and the rest of this code is unchanged.
Scaffold the project
pnpm create next-app@latest streamui-demo --ts --app --eslint --src-dir
cd streamui-demo
pnpm add ai@^3.1 @ai-sdk/openai@^0.0.40 zod
The streamUI function lives in ai/rsc, not the main ai entry point. Pin these versions—the RSC API is still experimental and shifts between minors.
Server action with streamUI
Create src/app/actions.tsx. This module runs on the server. We define a tool that returns a component and a text fallback for non-tool replies.
// src/app/actions.tsx
import { streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import WeatherCard from './components/weather-card';
export async function submitMessage(message: string) {
const result = await streamUI({
model: openai('gpt-4o'),
system: 'You are a weather bot. Use showWeather for any city query.',
messages: [{ role: 'user', content: message }],
text: ({ content }) => <p>{content}</p>,
tools: {
showWeather: {
parameters: z.object({ city: z.string() }),
generate: async function* ({ city }) {
const data = { city, temp: 21, condition: 'Partly cloudy' };
return <WeatherCard {...data} />;
},
},
},
});
return result;
}
generate can be an async generator that yields intermediate nodes, but for a single component a returned node is enough. The text renderer handles plain model output.
If you use a gateway, configure the provider like this before calling streamUI:
import { createOpenAI } from '@ai-sdk/openai';
const openai = createOpenAI({
baseURL: 'https://api.n4n.ai/v1',
apiKey: process.env.N4N_API_KEY,
});
The rendered component
src/app/components/weather-card.tsx is a plain server component:
export default function WeatherCard({ city, temp, condition }: {
city: string;
temp: number;
condition: string;
}) {
return (
<div className="card">
<h3>{city}</h3>
<p>{temp}°C · {condition}</p>
</div>
);
}
No 'use client' directive. It renders on the server and is streamed as an RSC payload.
Client wiring
The client uses useUIState and useActions from ai/rsc. Replace src/app/page.tsx:
'use client';
import { useUIState, useActions } from 'ai/rsc';
import { useState } from 'react';
import { submitMessage } from './actions';
export default function Page() {
const [input, setInput] = useState('');
const [messages, setMessages] = useUIState();
const { submitMessage: action } = useActions();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setMessages((prev) => [...prev, { role: 'user', content: input }]);
const response = await action(input);
setMessages((prev) => [...prev, { role: 'assistant', content: response.value }]);
setInput('');
}
return (
<div>
<form onSubmit={handleSubmit}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit">Send</button>
</form>
<div>
{messages.map((m, i) => (
<div key={i}>{m.content}</div>
))}
</div>
</div>
);
}
response.value is the React node streamed from streamUI. The useUIState hook stores these nodes across renders.
Checkpoint: first run
pnpm dev
Open http://localhost:3000. Type: What's the weather in Oslo?
Expected DOM after the stream resolves:
<div class="card">
<h3>Oslo</h3>
<p>21°C · Partly cloudy</p>
</div>
In the network tab you’ll see a single POST to the server action returning a fragmented RSC stream. If GPT-4o ignores the tool, you’ll get a <p> with its textual reply instead.
Forcing tool calls
GPT-4o sometimes answers from parametric memory. Force the tool with toolChoice:
const result = await streamUI({
model: openai('gpt-4o'),
system: 'You are a weather bot. Use showWeather for any city query.',
messages: [{ role: 'user', content: message }],
toolChoice: 'required',
text: ({ content }) => <p>{content}</p>,
tools: { /* same as before */ },
});
Now every message returns the component.
Adding a second component
Extend the action with a showStock tool:
import StockCard from './components/stock-card';
tools: {
showWeather: { /* ... */ },
showStock: {
parameters: z.object({ symbol: z.string() }),
generate: async function* ({ symbol }) {
return <StockCard symbol={symbol} price={182.33} />;
},
},
}
The client code does not change. vercel ai sdk streamui gpt-4o components scales to N tools because the SDK multiplexes the tool selection and streams whichever component the model picks.
Understanding the UI state shape
useUIState holds an array of { role, content }. The content for assistant turns is a React node, not a string. If you inspect it in dev tools you’ll see an object with $$typeof: Symbol(react.element). That’s expected—do not JSON.stringify it.
Production caveats
streamUIis experimental in AI SDK 3.x; lock the version inpackage.json.- RSC streams are dynamic. Ensure the route is not statically optimized (
export const dynamic = 'force-dynamic'in the page if needed). - Secrets stay in
generateon the server. The client only receives rendered markup. - Provider errors (rate limits, degradation) throw inside the stream. Wrap the
actioncall in try/catch and surface a fallback UI.
Why this beats manual orchestration
Without vercel ai sdk streamui gpt-4o components, you’d parse JSON tool calls, map them to component types, and serialize props yourself. The SDK collapses that into one function and streams React directly. The model output becomes UI without an intermediate representation you have to hand-code, which is the entire reason to use the RSC pipeline.