Real-Time AI in Next.js: Streaming and Server Components
# Build real-time AI features in Next.js 15 with the Vercel AI SDK: streaming text responses, Server Actions for LLM calls, and React Server Components for blazing-fast initial loads.
Next.js 15's App Router and the Vercel AI SDK make streaming AI responses surprisingly easy. The key is knowing which rendering model to use: Server Components for static AI-generated content, Server Actions for interactive streaming, and client-side hooks for real-time chat. Here's how to use each correctly.
The Rendering Model Decision
Static AI content (summaries, analysis)
→ React Server Components (RSC)
→ Render at request time, no client JS
Interactive streaming (chat, generation)
→ Server Actions + useActionState
→ Stream from server, update client progressively
Real-time chat with history
→ Vercel AI SDK useChat hook
→ Handles messages state, streaming, retry
Setup: Vercel AI SDK
npm install ai @ai-sdk/anthropic
// lib/ai.ts
import { createAnthropic } from "@ai-sdk/anthropic";
export const anthropic = createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
});
export const model = anthropic("claude-sonnet-4-6");
Pattern 1: Streaming Chat with useChat
The most common pattern — a chat interface that streams tokens in real time:
// app/chat/page.tsx
"use client";
import { useChat } from "ai/react";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: "/api/chat",
});
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
<div className="flex-1 overflow-y-auto space-y-4">
{messages.map((m) => (
<div key={m.id} className={`p-4 rounded-lg ${m.role === "user" ? "bg-blue-50 ml-12" : "bg-gray-50 mr-12"}`}>
<p className="text-sm font-medium mb-1">{m.role === "user" ? "You" : "AI"}</p>
<p className="whitespace-pre-wrap">{m.content}</p>
</div>
))}
{isLoading && <div className="animate-pulse">Thinking...</div>}
</div>
<form onSubmit={handleSubmit} className="flex gap-2 mt-4">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask anything..."
className="flex-1 border rounded-lg px-4 py-2"
/>
<button type="submit" disabled={isLoading} className="px-6 py-2 bg-blue-600 text-white rounded-lg">
Send
</button>
</form>
</div>
);
}
The API route:
// app/api/chat/route.ts
import { streamText } from "ai";
import { model } from "@/lib/ai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model,
system: "You are a helpful AI assistant for Shahriar Labs.",
messages,
});
return result.toDataStreamResponse();
}
toDataStreamResponse() sets the correct headers and streams tokens using the AI SDK's data stream protocol — useChat understands it natively.
Pattern 2: Server Actions for One-Shot Generation
For single-shot AI tasks (summarize, analyze, classify), Server Actions eliminate the need for an API route:
// app/actions.ts
"use server";
import { generateText, streamText } from "ai";
import { createStreamableValue } from "ai/rsc";
import { model } from "@/lib/ai";
export async function summarizeDocument(text: string) {
const { text: summary } = await generateText({
model,
prompt: `Summarize this document in 3 bullet points:\n\n${text}`,
maxTokens: 300,
});
return summary;
}
// Streaming version
export async function streamSummary(text: string) {
const stream = createStreamableValue("");
(async () => {
const { textStream } = streamText({
model,
prompt: `Summarize this document in 3 bullet points:\n\n${text}`,
});
for await (const chunk of textStream) {
stream.update(chunk);
}
stream.done();
})();
return stream.value;
}
// app/summarize/page.tsx
"use client";
import { useActionState } from "react";
import { readStreamableValue } from "ai/rsc";
import { streamSummary } from "@/app/actions";
export default function SummarizePage() {
const [summary, setSummary] = React.useState("");
async function handleSubmit(text: string) {
setSummary("");
const stream = await streamSummary(text);
for await (const chunk of readStreamableValue(stream)) {
setSummary((prev) => prev + chunk);
}
}
return (/* ... */);
}
Pattern 3: AI Content in React Server Components
For static AI-generated content that doesn't need interactivity:
// app/blog/[slug]/insights/page.tsx
import { generateText } from "ai";
import { model } from "@/lib/ai";
import { getPost } from "@/lib/posts";
// This component runs on the server — no client JS needed
export default async function PostInsights({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
// AI runs at render time, output cached by Next.js
const { text: insights } = await generateText({
model,
prompt: `Extract 3 key technical insights from this article:\n\n${post.content}`,
maxTokens: 200,
});
return (
<aside className="border-l-2 border-blue-200 pl-4 my-8">
<h3 className="font-bold mb-2">Key Insights</h3>
<p className="text-sm text-gray-600 whitespace-pre-wrap">{insights}</p>
</aside>
);
}
Cache the AI output with unstable_cache:
import { unstable_cache } from "next/cache";
const getCachedInsights = unstable_cache(
async (content: string) => {
const { text } = await generateText({ model, prompt: `Key insights: ${content}`, maxTokens: 200 });
return text;
},
["post-insights"],
{ revalidate: 86400 } // cache for 24 hours
);
Pattern 4: Structured Generation with Zod
Extract structured data from AI responses using generateObject:
// app/api/extract/route.ts
import { generateObject } from "ai";
import { z } from "zod";
import { model } from "@/lib/ai";
const ArticleSchema = z.object({
title: z.string(),
summary: z.string(),
tags: z.array(z.string()).max(5),
readingTimeMinutes: z.number(),
difficulty: z.enum(["beginner", "intermediate", "advanced"]),
});
export async function POST(req: Request) {
const { content } = await req.json();
const { object } = await generateObject({
model,
schema: ArticleSchema,
prompt: `Extract metadata from this article:\n\n${content}`,
});
return Response.json(object);
}
The SDK guarantees the response matches the Zod schema — no manual JSON parsing or validation needed.
Handling Errors in Streaming
// app/api/chat/route.ts
import { streamText } from "ai";
export async function POST(req: Request) {
try {
const { messages } = await req.json();
const result = streamText({
model,
messages,
onError: (error) => {
console.error("Stream error:", error);
},
});
return result.toDataStreamResponse({
getErrorMessage: (error) => {
if (error instanceof Error) return error.message;
return "An unexpected error occurred";
},
});
} catch (err) {
return new Response(
JSON.stringify({ error: "Failed to process request" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}
Client-side error handling:
const { messages, error } = useChat({
api: "/api/chat",
onError: (error) => console.error("Chat error:", error),
});
if (error) return <div>Error: {error.message}</div>;
Performance Tips
- Stream tokens — never use
generateTextfor user-facing responses; always stream. Users see output 2–3× faster. - Cache RSC outputs — AI calls in Server Components are expensive; always wrap with
unstable_cache. - Use
maxTokens— always set a limit. Unbounded generation blocks the stream and costs money. - Abort on disconnect — the AI SDK handles client disconnects automatically when using
toDataStreamResponse.
See TypeScript Patterns for LLM Integration for type-safe tool definitions in Next.js.
FAQ
Should I use Vercel AI SDK or call Anthropic directly? Use the AI SDK for UI-heavy apps — it handles streaming protocol, error recovery, and message state. Call Anthropic directly in backend services where you need full control.
How do I add tool use to useChat?
Define tools on the server in the streamText call. The SDK handles the tool-call / tool-result round-trip automatically in the useChat hook.
Can I use App Router streaming with non-Vercel hosting?
Yes — the AI SDK's streaming works on any Node.js runtime. Cloudflare Workers require the edge runtime (export const runtime = "edge").
How do I persist chat history?
Store messages in a database (Postgres, SQLite) keyed by session ID. Pass stored messages as initialMessages to useChat. The hook appends new messages; save them on onFinish.
What's the difference between streamText and streamObject?
streamText streams tokens as they're generated. streamObject streams a partial JSON object as it's built — useful for streaming structured data like lists or forms.