Streaming AI Responses in Next.js with the AI SDK
Vercel AI SDK setup, streaming text with useChat, structured output with streamObject, error boundaries, and token budgeting.
Streaming AI responses changes the UX from "wait 5 seconds for text to appear" to "watch the response build character by character." The Vercel AI SDK makes this straightforward in Next.js. Here's the full setup.
Setup
npm install ai @ai-sdk/anthropicBasic Streaming Chat Route
// app/api/chat/route.ts
import { anthropic } from "@ai-sdk/anthropic";
import { streamText } from "ai";
export const maxDuration = 60;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: anthropic("claude-haiku-4-5-20251001"),
system: "You are NodWatch's AI assistant. Help users find movies and shows.",
messages,
maxTokens: 1024,
});
return result.toDataStreamResponse();
}useChat on the Client
"use client";
import { useChat } from "ai/react";
export function AiChat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: "/api/chat",
});
return (
<div>
<div className="space-y-4">
{messages.map((m) => (
<div key={m.id} className={m.role === "user" ? "text-right" : "text-left"}>
<span className="rounded-lg bg-white/10 px-4 py-2 text-sm">{m.content}</span>
</div>
))}
{isLoading && <div className="animate-pulse text-sm text-white/40">Thinking...</div>}
</div>
<form onSubmit={handleSubmit} className="mt-4 flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask about movies..."
className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm"
/>
<button type="submit" disabled={isLoading}>Send</button>
</form>
{error && <p className="text-sm text-red-400">{error.message}</p>}
</div>
);
}Streaming Structured Output
streamObject streams a JSON object incrementally — useful for generating structured recommendations:
// app/api/recommendations/route.ts
import { anthropic } from "@ai-sdk/anthropic";
import { streamObject } from "ai";
import { z } from "zod";
const recommendationSchema = z.object({
recommendations: z.array(z.object({
title: z.string(),
year: z.number(),
reason: z.string(),
genre: z.string(),
})),
summary: z.string(),
});
export async function POST(req: Request) {
const { watchHistory } = await req.json();
const result = await streamObject({
model: anthropic("claude-haiku-4-5-20251001"),
schema: recommendationSchema,
prompt: `Based on this watch history: ${JSON.stringify(watchHistory)}, recommend 5 movies or shows.`,
});
return result.toTextStreamResponse();
}On the client:
import { experimental_useObject as useObject } from "ai/react";
export function Recommendations() {
const { object, submit, isLoading } = useObject({
api: "/api/recommendations",
schema: recommendationSchema,
});
return (
<div>
<button onClick={() => submit({ watchHistory })}>Get Recommendations</button>
{object?.recommendations?.map((rec, i) => (
<div key={i}>
<strong>{rec?.title}</strong> ({rec?.year})
<p>{rec?.reason}</p>
</div>
))}
</div>
);
}The object renders as it streams — fields appear as they're generated.
Token Budgeting
Control costs with maxTokens:
const result = await streamText({
model: anthropic("claude-haiku-4-5-20251001"),
messages,
maxTokens: 512, // Short answers stay cheap
onFinish: ({ usage }) => {
console.log(`Tokens: ${usage.promptTokens} in, ${usage.completionTokens} out`);
},
});Claude Haiku is the right model for interactive features — fast, cheap, good enough for recommendations and search assistance. Reserve Sonnet/Opus for batch jobs where quality matters more than latency and cost.
Error Handling
The AI SDK throws on API errors. Wrap with a try/catch and return a proper error response:
try {
const result = await streamText({ model, messages });
return result.toDataStreamResponse();
} catch (error) {
if (error instanceof Error && error.message.includes("overloaded")) {
return new Response("AI service is busy. Please try again.", { status: 503 });
}
return new Response("Failed to generate response.", { status: 500 });
}On the client, useChat's error state surfaces these — render it near the input so the user knows what happened.
Rate Limiting AI Routes
AI routes are expensive to call — rate limit them aggressively:
import { authRatelimit } from "@/lib/rate-limit";
export async function POST(req: Request) {
const session = await getSession();
if (!session) return new Response("Unauthorized", { status: 401 });
const { success } = await authRatelimit.limit(`ai:${session.user.id}`);
if (!success) return new Response("Too many AI requests", { status: 429 });
// proceed...
}5 requests per minute per user is reasonable for chat; adjust for your cost tolerance.