Vercel AI SDK: Building Streaming AI Chat in Next.js
Prologue: What Happened When I Rolled My Own AI Chat
The first time I added AI chat to a Next.js app, I hand-rolled everything with fetch. Parse the streaming response, manage loading state, maintain message history, handle aborts... it turned out to be way more work than expected.
Then when I tried to switch from OpenAI to Anthropic, the API shapes were different enough that I had to rewrite a significant chunk of code.
That's when I found the Vercel AI SDK. One sentence summary:
"A library that eliminates the boilerplate of AI chat apps."
The useChat hook handles streaming, loading state, and message history out of the box. Switching providers is a one-line change.
My code shrank to roughly one-third of what it was before.
1. Installation and Architecture
npm install ai @ai-sdk/openai @ai-sdk/anthropic
The SDK has three layers:
ai # Core SDK (provider-agnostic)
├── @ai-sdk/openai # OpenAI provider
├── @ai-sdk/anthropic # Anthropic provider
└── @ai-sdk/google # Google AI provider
The core philosophy is provider separation. The core API is identical regardless of which LLM service you use.
// Using OpenAI
import { openai } from "@ai-sdk/openai";
const model = openai("gpt-4o");
// Switch to Anthropic — that's it
import { anthropic } from "@ai-sdk/anthropic";
const model = anthropic("claude-opus-4-5");
2. Basic Streaming with streamText
Server Side (Route Handler)
// 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 = await streamText({
model: openai("gpt-4o"),
messages,
system: "You are a helpful AI assistant.",
});
return result.toDataStreamResponse();
}
Eight lines. streamText calls the LLM, toDataStreamResponse() converts the result into a streaming response the client can consume.
Client Side (useChat)
"use client";
import { useChat } from "ai/react";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
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((message) => (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`rounded-lg px-4 py-2 max-w-[80%] ${
message.role === "user"
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-900"
}`}
>
{message.content}
</div>
</div>
))}
{isLoading && <div className="animate-pulse">Thinking...</div>}
</div>
{error && (
<div className="bg-red-50 text-red-700 p-3 rounded mb-4">
Error: {error.message}
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-2 mt-4">
<input
value={input}
onChange={handleInputChange}
className="flex-1 border rounded-lg px-4 py-2"
disabled={isLoading}
/>
<button type="submit" disabled={isLoading || !input.trim()}>
Send
</button>
</form>
</div>
);
}
What useChat returns:
| Return value | Type | Description |
|---|
messages | Message[] | Full conversation history |
input | string | Current input value |
isLoading | boolean | Waiting for response |
error | Error | undefined | Error info |
reload | Function | Retry last message |
stop | Function | Abort streaming |
3. Tool Integration
The AI SDK treats Tool Use as a first-class feature.
// app/api/chat/route.ts
import { streamText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: anthropic("claude-opus-4-5"),
messages,
tools: {
getWeather: tool({
description: "Get the current weather for a city",
parameters: z.object({
city: z.string().describe("City name"),
unit: z.enum(["celsius", "fahrenheit"]).optional(),
}),
execute: async ({ city, unit = "celsius" }) => ({
city,
temperature: 22,
unit,
condition: "sunny",
}),
}),
},
maxSteps: 5,
});
return result.toDataStreamResponse();
}
The client can inspect tool call state in real time via toolInvocations:
{message.toolInvocations?.map((inv) => (
<div key={inv.toolCallId} className="text-xs bg-gray-100 rounded p-2 mt-1">
{inv.state === "result" ? "✓" : "⟳"} {inv.toolName}
{inv.state === "result" && (
<pre className="mt-1">{JSON.stringify(inv.result, null, 2)}</pre>
)}
</div>
))}
4. Error Handling and Loading States
// Retry wrapper for rate limits
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (i === maxRetries - 1) throw error;
const isRateLimit = error instanceof Error && error.message.includes("429");
await new Promise(r => setTimeout(r, isRateLimit ? 2000 * (i + 1) : 1000));
}
}
throw new Error("Unreachable");
}
// Stop button for streaming
{isLoading ? (
<button onClick={stop} className="bg-red-500 text-white px-4 py-2 rounded">
Stop
</button>
) : (
<button type="submit" disabled={!input.trim()}>
Send
</button>
)}
// Retry button on error
{error && (
<div className="flex items-center gap-2 text-red-600">
<span>{error.message}</span>
<button onClick={() => reload()} className="underline">
Retry
</button>
</div>
)}
5. Provider Abstraction
// lib/ai.ts — pick a model by preset
import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
import type { LanguageModel } from "ai";
type Preset = "fast" | "standard" | "powerful";
const PRESETS: Record<Preset, () => LanguageModel> = {
fast: () => openai("gpt-4o-mini"),
standard: () => anthropic("claude-sonnet-4-5"),
powerful: () => anthropic("claude-opus-4-5"),
};
export const getModel = (preset: Preset = "standard") => PRESETS[preset]();
Use it in route handlers:
import { getModel } from "@/lib/ai";
const result = await streamText({
model: getModel(process.env.NODE_ENV === "production" ? "powerful" : "fast"),
messages,
});
Epilogue: What the AI SDK Actually Solves
Three things I appreciate most after switching to the AI SDK:
- Provider abstraction: One-line swap when a provider goes down.
- Streaming handled automatically: No SSE parsing, no buffer management.
- Type safety: Zod-based tool definitions catch errors at compile time.
The tradeoff is that heavy abstraction makes debugging harder when something breaks internally. But for the vast majority of projects, the SDK is the right call. You simply won't encounter the streaming bugs and state inconsistencies that come with rolling your own.
If you're adding AI chat to a Next.js app, start with the AI SDK. You'll have a production-ready chat UI far faster than you'd expect.