The Vercel AI SDK’s server actions and streaming UI primitives let you move LLM orchestration off the client entirely. You write async generators on the server, stream React components as they resolve, and the client hydrates them incrementally — no WebSocket management, no custom SSE parsing. This guide walks through a production-ready pattern: a chat interface that streams tool calls, renders intermediate UI, and handles failures without leaving the user staring at a spinner.
Step 1: Initialize the project with the right dependencies
Start from a fresh Next.js 14+ app with the App Router. You need the AI SDK core, the React integration, and the OpenAI provider (swap for Anthropic, Google, or any OpenAI-compatible endpoint if you prefer).
npx create-next-app@latest ai-streaming-demo --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd ai-streaming-demo
npm install ai @ai-sdk/react @ai-sdk/openai zod
The ai package contains the server-side primitives (streamText, streamUI, tool). The @ai-sdk/react package exports useChat and the client-side streaming helpers. Zod validates tool parameters at runtime — skip it only if you enjoy debugging malformed function calls at 2 AM.
Verify the install works:
npm run dev
Open http://localhost:3000. You should see the default Next.js landing page.
Step 2: Create the server action that streams UI
Server actions in Next.js are async functions marked with "use server". The AI SDK’s streamUI function returns a ReadableStream<ReactNode> that the client can consume directly. This is where the vercel ai sdk server actions streaming ui pattern lives — the server decides what component to render, when to render it, and with what props.
Create src/app/actions/chat.ts:
"use server";
import { streamUI } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { SearchResults, WeatherCard, ErrorMessage } from "@/components/chat-ui";
const weatherTool = {
parameters: z.object({
location: z.string().describe("City and state, e.g. 'San Francisco, CA'"),
unit: z.enum(["celsius", "fahrenheit"]).default("fahrenheit"),
}),
execute: async ({ location, unit }) => {
// In production, call a real weather API. Here we simulate latency and occasional failure.
await new Promise((r) => setTimeout(r, 800));
if (Math.random() < 0.1) throw new Error("Weather provider unavailable");
const temp = unit === "celsius" ? 18 : 64;
return { location, temperature: temp, unit, condition: "Partly cloudy" };
},
};
const searchTool = {
parameters: z.object({
query: z.string().describe("Search query"),
maxResults: z.number().default(5),
}),
execute: async ({ query, maxResults }) => {
await new Promise((r) => setTimeout(r, 1200));
return {
query,
results: Array.from({ length: maxResults }, (_, i) => ({
title: `Result ${i + 1} for "${query}"`,
url: `https://example.com/result-${i + 1}`,
snippet: `This is a simulated snippet for result ${i + 1}.`,
})),
};
},
};
export async function continueConversation(messages: Array<{ role: "user" | "assistant"; content: string }>) {
const result = streamUI({
model: openai("gpt-4o"),
system: "You are a helpful assistant with access to weather and search tools. " +
"When users ask about weather, call the weather tool. " +
"When they ask for facts or recent info, call search. " +
"Stream the appropriate UI component for each tool result.",
messages: messages.map((m) => ({ role: m.role, content: m.content })),
tools: {
weather: weatherTool,
search: searchTool,
},
// The generate function maps tool calls to React components.
// Return null to emit nothing for a given step (e.g., internal reasoning).
generate: async function* ({ toolCall }) {
if (toolCall.toolName === "weather") {
const args = toolCall.args as { location: string; unit: "celsius" | "fahrenheit" };
try {
const data = await weatherTool.execute(args);
yield <WeatherCard key={toolCall.toolCallId} data={data} />;
} catch (err) {
yield <ErrorMessage key={toolCall.toolCallId} message={`Weather lookup failed: ${err}`} />;
}
return; // Don't yield text for this tool call
}
if (toolCall.toolName === "search") {
const args = toolCall.args as { query: string; maxResults: number };
const data = await searchTool.execute(args);
yield <SearchResults key={toolCall.toolCallId} data={data} />;
return;
}
// For text deltas, yield nothing here — the text streams via the default text part.
},
});
return result.value; // This is a ReadableStream<ReactNode>
}
A few things to notice:
- The
"use server"directive at the top makes this a server action. Next.js serializes the returnedReadableStreamacross the network boundary automatically. streamUIaccepts agenerateasync generator. Eachyieldemits a React node into the stream. The client receives these in order and renders them as they arrive.- Tools are defined inline with Zod schemas. The
executefunctions run on the server — your API keys never touch the client. - Errors inside tool execution become UI components, not thrown exceptions that crash the stream.
Step 3: Build the streaming UI components
The components yielded from the server action need to be serializable — they’re sent as React Server Component payloads, not hydrated client components. Keep them pure, avoid useState/useEffect, and pass all data as props.
Create src/components/chat-ui.tsx:
import { Metadata } from "next";
export interface WeatherData {
location: string;
temperature: number;
unit: "celsius" | "fahrenheit";
condition: string;
}
export interface SearchData {
query: string;
results: Array<{ title: string; url: string; snippet: string }>;
}
export function WeatherCard({ data }: { data: WeatherData }) {
const unitLabel = data.unit === "celsius" ? "°C" : "°F";
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 my-2">
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 00-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
Weather for {data.location}
</div>
<div className="mt-2 flex items-baseline gap-4">
<span className="text-3xl font-bold text-slate-900">{data.temperature}{unitLabel}</span>
<span className="text-slate-600 capitalize">{data.condition}</span>
</div>
</div>
);
}
export function SearchResults({ data }: { data: SearchData }) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 my-2">
<div className="text-sm font-medium text-slate-700 mb-3">
Search results for “{data.query}”
</div>
<ul className="space-y-3">
{data.results.map((result, i) => (
<li key={i} className="border-t border-slate-100 pt-3 first:border-0 first:pt-0">
<a href={result.url} target="_blank" rel="noopener noreferrer" className="font-medium text-blue-600 hover:underline">
{result.title}
</a>
<p className="mt-1 text-sm text-slate-600">{result.snippet}</p>
<cite className="text-xs text-slate-400">{result.url}</cite>
</li>
))}
</ul>
</div>
);
}
export function ErrorMessage({ message }: { message: string }) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 my-2 text-sm text-red-700">
⚠ {message}
</div>
);
}
These components are deliberately simple. They receive data, render HTML, and do nothing else. The server action decides which component to send and when. The client just renders the stream.
Step 4: Wire the client to consume the stream
The client uses useChat from @ai-sdk/react with a custom onFinish handler that reads the server action’s stream. Create src/app/page.tsx:
"use client";
import { useChat } from "@ai-sdk/react";
import { useState, useCallback } from "react";
import { continueConversation } from "@/app/actions/chat";
export default function ChatPage() {
const [streamingUI, setStreamingUI] = useState<React.ReactNode[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const { messages, input, handleInputChange, handleSubmit, status } = useChat({
api: "/api/chat", // We'll create this route in Step 5
onFinish: async (message, { usage, finishReason }) => {
console.log("Chat finished:", { finishReason, usage });
},
onError: (err) => {
console.error("Chat error:", err);
alert("Something went wrong. Check the console.");
},
});
// This handler calls the server action directly and renders the streamed UI.
const handleStreamingSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
setIsStreaming(true);
setStreamingUI([]);
// Optimistically add user message
const userMessage = { role: "user" as const, content: input };
// Note: useChat manages its own message state via the API route.
// For a pure server-action flow, you'd manage messages locally.
// Here we demonstrate both: the API route for text, server action for UI.
try {
const stream = await continueConversation([
...messages.map((m) => ({ role: m.role, content: m.content })),
userMessage,
]);
// Read the ReadableStream<ReactNode> and update state per chunk
const reader = stream.getReader();
const decoder = new TextDecoder(); // Not used for ReactNode, but kept for debugging
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value is a ReactNode (element, string, number, etc.)
setStreamingUI((prev) => [...prev, value]);
}
} catch (err) {
console.error("Stream error:", err);
setStreamingUI((prev) => [
...prev,
<div key="error" className="text-red-600 p-2">Stream failed: {String(err)}</div>,
]);
} finally {
setIsStreaming(false);
}
}, [input, isStreaming, messages]);
return (
<main className="max-w-2xl mx-auto p-4">
<h1 className="text-2xl font-semibold mb-6">Streaming UI with Server Actions</h1>
<div className="space-y-4 mb-6 border-t border-slate-200 pt-4">
{messages.map((m, i) => (
<div key={i} className={`flex gap-3 ${m.role === "user" ? "justify-end" : ""}`}>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
m.role === "user"
? "bg-blue-600 text-white rounded-br-none"
: "bg-slate-100 text-slate-900 rounded-bl-none"
}`}
>
{m.content}
</div>
</div>
))}
{streamingUI.map((node, i) => (
<div key={i} className="flex justify-start">{node}</div>
))}
</div>
<form onSubmit={handleStreamingSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
disabled={isStreaming}
placeholder="Ask about weather or search for something..."
className="flex-1 rounded-lg border border-slate-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isStreaming || !input.trim()}
className="rounded-lg bg-blue-600 px-6 py-2 text-white font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isStreaming ? "Streaming…" : "Send"}
</button>
</form>
{status === "streaming" && <p className="mt-2 text-sm text-slate-500">Generating response…</p>}
</main>
);
}
This component does two things in parallel:
- Uses
useChatwith an API route (Step 5) for the standard text streaming path — this handles the assistant’s textual response. - Calls
continueConversationserver action directly for the UI stream — this renders tool results as cards, tables, or whatever components you define.
In production you’d probably unify these into a single stream, but separating them makes the data flow explicit for learning.
Step 5: Create the API route for text streaming
The useChat hook expects an endpoint that returns a ReadableStream of text deltas. Create src/app/api/chat/route.ts:
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
system: "You are a helpful assistant. Keep responses concise.",
messages,
// Tools are defined in the server action. We don't duplicate them here.
// The API route handles only the text stream.
});
return result.toDataStreamResponse();
}
This is the standard AI SDK pattern. streamText handles the OpenAI SSE stream, parses deltas, and returns a DataStreamResponse that useChat consumes. No custom parsing logic required.
Step 6: Handle the client hydration boundary
The components streamed from the server action are React Server Components. They render on the server, serialize as RSC payload, and hydrate on the client. But the parent component (ChatPage) is a client component ("use client"). Next.js handles this boundary automatically — server components can be children of client components.
However, if your streamed components need interactivity (buttons, forms, their own state), mark them with "use client" at the top of their file. The RSC payload will then include the client component reference, and the client will hydrate them.
For example, if WeatherCard needed a “Refresh” button:
// src/components/chat-ui.tsx
"use client"; // Add this line
export function WeatherCard({ data }: { data: WeatherData }) {
const [refreshed, setRefreshed] = useState(false);
// ... now you can use useState, useEffect, etc.
}
The tradeoff: client components increase bundle size and hydration cost. Keep the streamed UI as server components unless you genuinely need client-side interactivity.
Step 7: Add error handling and retry logic
Production streams fail. Networks hiccup. Providers return 5xx. The AI SDK’s streamUI and streamText both support onError callbacks, but you also need client-side resilience.
Update src/app/actions/chat.ts to wrap the stream in a retry helper:
// Add at top of file
async function withRetry<T>(
fn: () => Promise<T>,
attempts = 3,
baseDelay = 1000
): Promise<T> {
let lastError: Error;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastError = err as Error;
if (i < attempts - 1) {
await new Promise((r) => setTimeout(r, baseDelay * 2 ** i));
}
}
}
throw lastError!;
}
// Then wrap the streamUI call:
export async function continueConversation(messages: Array<{ role: "user" | "assistant"; content: string }>) {
const result = await withRetry(() =>
streamUI({
// ... same config as before
})
);
return result.value;
}
On the client, surface retry UI:
// In ChatPage, add to state:
const [streamError, setStreamError] = useState<string | null>(null);
// In handleStreamingSubmit catch block:
setStreamError(String(err));
// In JSX, after the form:
{streamError && (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 flex gap-2 items-center">
<span>Stream failed: {streamError}</span>
<button
onClick={() => { setStreamError(null); handleStreamingSubmit(e as any); }}
className="text-amber-700 underline hover:text-amber-900"
>
Retry
</button>
</div>
)}
This gives users a path forward without refreshing the page.
Step 8: Verify the implementation end to end
Run the dev server and test each path:
npm run dev
Test 1: Basic text streaming
- Open
http://localhost:3000 - Type “Hello, how are you?”
- Verify the assistant’s text appears token-by-token in the chat history (via
useChat)
Test 2: Tool call with UI streaming
- Type “What’s the weather in Seattle?”
- Watch for:
- Assistant text: “I’ll check the weather for you…” (text stream)
- A
WeatherCardcomponent appears inline with temperature, condition, location (UI stream)
- The card renders before the assistant’s final text completes — this is the streaming UI advantage
Test 3: Search tool
- Type “Search for recent TypeScript 5.5 features”
- Verify
SearchResultsrenders with clickable links
Test 4: Error simulation
- The weather tool fails 10% of the time. Keep asking until you see the red
ErrorMessagecomponent - Click “Retry” — the server action re-executes, potentially succeeding
Test 5: Network resilience
- In browser DevTools, Network tab, enable “Offline”
- Submit a message — verify the retry UI appears
- Disable Offline, click Retry — verify recovery
Test 6: Hydration check
- View page source (Ctrl+U) after a tool result renders
- Search for the component’s HTML — it exists in the initial payload, not injected via JS
- This confirms RSC streaming works correctly
Step 9: Deploy and observe production behavior
Deploy to Vercel (or any Node.js host with Next.js support):
npx vercel deploy
In production, watch for:
- Cold starts: The first request to a server action incurs function initialization. Keep dependencies minimal.
- Stream timeout: Vercel’s default function timeout is 60s (Pro) or 10s (Hobby). Long-running tool chains may need
maxDurationinvercel.jsonor background job offloading. - Provider failures: If you’re routing through a gateway like n4n.ai, automatic fallback across 240+ models means a single provider outage doesn’t break your stream — the gateway retries transparently and forwards
cache-controlhints so you can cache idempotent tool results. - Metering: Per-token usage from the AI SDK’s
usagecallback lets you attribute costs per conversation, per tool call, per user.
Step 10: Extend the pattern for your use cases
The core pattern — server action returns ReadableStream<ReactNode>, client reads and renders — applies far beyond chat:
- Dashboard widgets: Stream KPI cards, charts, and tables as backend queries complete in parallel
- Form wizards: Render each step’s UI after validating the previous step server-side
- Report generation: Stream sections (executive summary, charts, appendix) as the LLM composes them
- Code review: Stream file diffs, lint results, and suggestions as separate components
Each follows the same structure:
- Define tools with Zod schemas and server-side
executefunctions - Write a
streamUIserver action with agenerateasync generator - Yield server components for each tool result or reasoning step
- Consume the stream on the client with a simple reader loop
The vercel ai sdk server actions streaming ui approach eliminates the impedance mismatch between backend orchestration and frontend rendering. You stop building APIs that return JSON for the client to interpret, and start streaming the interpretation itself.