Back to Blog
May 26, 20264 min readOnuzulike Anthony

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.

AI/MLAINext.jsStreaming

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

bash
npm install ai @ai-sdk/anthropic

Basic Streaming Chat Route

ts
// 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

tsx
"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:

ts
// 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:

tsx
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:

ts
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:

ts
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:

ts
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.