
Streaming Chat + Tool Calling with Claude and the Vercel AI SDK 7 (Next.js + TypeScript)
Install ai + @ai-sdk/anthropic + zod, then in a Next.js Route Handler call streamText({ model: anthropic('claude-sonnet-5'), messages }) and stream it to a useChat client. Add tools with a Zod inputSchema plus execute so the SDK runs the multi-step loop; bound it with stopWhen. The AI SDK 7 (2026-06-25) is ESM-only.
The short version: streaming chat + tool calling with Claude in Next.js
The Vercel AI SDK streams Claude responses in Next.js and runs the tool-calling loop for you. In a Route Handler, call streamText({ model: anthropic('claude-sonnet-5'), messages }) and return result.toUIMessageStreamResponse(); on the client, render it with the useChat hook. Add tools via a tools object where each tool has a description, a Zod inputSchema, and an execute function — the SDK then runs the multi-step tool loop, and you bound it with stopWhen.
One gotcha up front: the Vercel AI SDK 7 (released 2026-06-25) is ESM-only, so require() will not work — your project needs "type": "module" and import syntax. The rest of this post is the production version of that: install, the model-id caveat, the Route Handler and client, tools with the multi-step loop, loop bounding, error and abort handling, and when to reach for the raw Claude Messages API instead.
What the Vercel AI SDK is (and why v7 for Claude)
The Vercel AI SDK is the de-facto TypeScript toolkit for building LLM apps — chat, streaming, tool calling, and structured output — across many providers behind one API. Most of the streaming and tool-calling plumbing you would otherwise hand-roll is already solved and battle-tested, which is why it is the fastest path to a working Claude chat in a Node or Next.js product rather than a Python backend.
The current major version is AI SDK 7, released 2026-06-25. It is agent-focused, and the headline operational change is that it is ESM-only. There is no CommonJS build: const { streamText } = require('ai') throws. If your package.json lacks "type": "module", add it, and give any config files that must stay CommonJS the .cjs extension. In a modern Next.js App Router project this is usually already handled, but it is the first thing to check when an import mysteriously fails.
Because the SDK’s APIs moved across major versions, pin your mental model to “AI SDK 7” and verify each method name against ai-sdk.dev as you write. A snippet from an older blog post is the single most common source of code that won’t compile.
Install: ai + @ai-sdk/anthropic + zod
You need three packages: the core ai package, the Claude provider adapter, and Zod for tool and output schemas.
npm install ai @ai-sdk/anthropic zod
For the client hook, you also need the React binding:
npm install @ai-sdk/react
The Claude provider is @ai-sdk/anthropic. Import the ready-made provider and call it with a model id:
import { anthropic } from '@ai-sdk/anthropic';
const model = anthropic('claude-sonnet-5');
The provider reads ANTHROPIC_API_KEY from the environment, so set it in .env.local:
ANTHROPIC_API_KEY=sk-ant-...
If you need a custom setup — a different baseURL, an explicit apiKey, extra headers, or a custom fetch — use createAnthropic instead of the default export:
import { createAnthropic } from '@ai-sdk/anthropic';
const anthropic = createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
headers: { 'anthropic-version': '2023-06-01' },
});
The model-id caveat: pass a current Claude id
anthropic('<model-id>') takes a plain model-id string. Pass a current one:
anthropic('claude-sonnet-5')— balanced, a strong default for chat and tool calling.anthropic('claude-opus-4-8')— the most capable, for the hardest reasoning and long-horizon agentic work.
Do not copy the example ids in the AI SDK docs — they lag behind real model releases, so you will find stale strings like claude-sonnet-4-20250514 in code samples that either 404 or route you to an older model. Verify the id against the AI SDK Anthropic provider docs when you write the code, and pull it into a constant so there is one place to update:
import { anthropic } from '@ai-sdk/anthropic';
export const CHAT_MODEL = anthropic('claude-sonnet-5');
A streaming chat in Next.js: the Route Handler + the useChat client
A streaming chat is two files: a server Route Handler that calls streamText and returns the stream, and a client component that renders it with useChat.
Here is the Route Handler at app/api/chat/route.ts:
import { anthropic } from '@ai-sdk/anthropic';
import { streamText } from 'ai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-5'),
messages,
});
return result.toUIMessageStreamResponse();
}
streamText returns a result object immediately — it does not block on the full generation — and toUIMessageStreamResponse() turns it into a Response the handler returns directly. That method name is what useChat expects; the older toDataStreamResponse from earlier SDK versions does not exist in AI SDK 7. When you copy a Route Handler from an old post, this is the first thing to fix — check the AI SDK docs for the current helper.
The client component uses useChat, imported from @ai-sdk/react. In AI SDK 7 the hook returns messages and sendMessage — it does not manage the input box for you, so you hold the input in your own useState, and you render each message from its parts array rather than a single content string:
'use client';
import { useState } from 'react';
import { useChat } from '@ai-sdk/react';
export default function Chat() {
const { messages, sendMessage } = useChat();
const [input, setInput] = useState('');
return (
<div>
{messages.map((message) => (
<div key={message.id}>
<strong>{message.role}:</strong>{' '}
{message.parts.map((part, i) =>
part.type === 'text' ? <span key={i}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Say something..." />
<button type="submit">Send</button>
</form>
</div>
);
}
useChat manages the message array and the fetch to /api/chat — its default endpoint. Each streamed token appends to the last assistant message’s parts, so the UI updates live as Claude generates.
Tool calling: description + Zod inputSchema + execute
Tool calling is where the SDK earns its keep. You pass a tools object to streamText (or generateText). Each tool declares three things: a description that tells Claude when to reach for it, a Zod inputSchema that defines and validates the arguments, and an execute function that actually runs the work and returns a result.
import { anthropic } from '@ai-sdk/anthropic';
import { streamText, tool, isStepCount } from 'ai';
import { z } from 'zod';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-5'),
messages,
tools: {
getWeather: tool({
description:
'Get the current weather for a city. Call this when the user asks about weather or temperature.',
inputSchema: z.object({
city: z.string().describe('City name, e.g. "Mexico City"'),
}),
execute: async ({ city }) => {
const res = await fetch(`https://api.example.com/weather?city=${encodeURIComponent(city)}`);
const data = await res.json();
return { city, tempC: data.tempC, conditions: data.conditions };
},
}),
},
stopWhen: isStepCount(5),
});
return result.toUIMessageStreamResponse();
}
Because each tool has an execute function, the SDK runs the multi-step loop for you: Claude calls getWeather, the SDK runs execute, feeds the result back, and Claude produces the next step — text, or another tool call — until it is done. You never write the manual “check stop_reason, append tool_result, re-request” loop that the raw Messages API requires. Note the description is prescriptive about when to call the tool, not just what it does; that trigger phrasing measurably improves how reliably Claude reaches for the right tool.
- Claude emits a tool callvalidated against the Zod inputSchema
- SDK runs execute()your function does the real work
- result returns to Claudeas a tool result the model can read
- next stepmore text, another tool, or done
Bounding and controlling the loop with stopWhen
Left unbounded, a tool loop can run indefinitely — Claude calls a tool, reads the result, calls another, and so on. In AI SDK 7 you bound the loop with stopWhen. In the example above, stopWhen: isStepCount(5) stops the loop after at most five steps. Verify the exact helper against the current docs when you write this — the loop-control option evolved across versions (earlier versions used a top-level maxSteps), so a copied maxSteps snippet will not match your installed SDK.
Pick a step budget that matches your task. A single-tool lookup rarely needs more than a couple of steps; a research agent that chains several tools may need more. Setting a ceiling is not optional in production — it is your safety net against a runaway loop burning tokens and latency:
import { isStepCount } from 'ai';
const result = streamText({
model: anthropic('claude-sonnet-5'),
messages,
tools: { /* ... */ },
stopWhen: isStepCount(8), // hard cap on tool-loop steps
});
Error and abort handling for production
Two things separate a demo from something you ship: handling errors and honoring cancellation.
Streaming errors. When you return a streamed response, errors that occur mid-stream are surfaced through the stream, not thrown from the initial streamText call. Provide onError so failures are logged rather than swallowed, and wrap the handler so setup-time errors — a bad request body, a missing env var — return a clean HTTP error:
export async function POST(req: Request) {
try {
const { messages } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-5'),
messages,
onError({ error }) {
console.error('stream error', error);
},
});
return result.toUIMessageStreamResponse();
} catch (err) {
console.error('handler error', err);
return new Response('Internal error', { status: 500 });
}
}
Abort handling. If the user closes the tab or clicks stop, you do not want the request to keep generating (and keep billing). Forward the request’s AbortSignal to streamText so the underlying Claude call is cancelled when the client disconnects:
const result = streamText({
model: anthropic('claude-sonnet-5'),
messages,
abortSignal: req.signal,
});
On the client, useChat exposes a stop function you can wire to a “Stop generating” button. Together, onError and abortSignal are the difference between a chat that leaks work and one that behaves under real traffic.
Vercel AI SDK 7
- Runs the multi-step tool loop for you
- useChat client state out of the box
- One API across many providers
- ESM-only; method names move across versions
Raw Claude Messages API
- Full control of the tool loop and stop reasons
- Direct access to every Claude-specific parameter
- No abstraction layer to track across versions
- You build streaming and tool plumbing yourself
AI SDK vs the raw Claude Messages API: when to reach for each
Reach for the Vercel AI SDK when you want the tool loop, streaming, and client-side chat state handled for you, and when provider portability or the React useChat hook matters. For most Next.js chat and agent features, it is the fastest path to something correct and streaming.
Reach for the raw Claude Messages API (covered in how to use the Claude API) when you need full control of the agentic loop — human-in-the-loop approval before each tool runs, custom per-step logging, conditional execution, or direct access to Claude-only parameters (adaptive thinking, effort, prompt caching breakpoints, server-side tools) that the abstraction does not expose one-to-one. If you find yourself fighting the SDK to get at a Claude feature, that is the signal to drop to the Messages API for that call.
The wedge, cross-links, and next steps
There is plenty of generic AI SDK content and plenty of Claude content, but very little that connects the two for a real Next.js deployment. From here:
- Structured output.
generateObjectis the sibling ofstreamText: pass a Zodschemaand get a typedobjectback, with automatic retry and repair on invalid output. It is the AI SDK counterpart to Anthropic’s own schema-guaranteed mode — see the generateObject + Zod structured outputs sibling post, and Claude structured outputs in production for Anthropic’s native approach. - RAG. Once tools work, the natural next step is grounding answers in your own data — see build a RAG system with Claude.
- Agents. For the conceptual foundation behind the multi-step loop, see what is an AI agent and the broader AI agents hub.
Two rules to carry forward: pin your work to AI SDK 7 and verify each moving method name (toUIMessageStreamResponse, stopWhen, the useChat import) against ai-sdk.dev as you write, and always pass a current Claude model id rather than the docs’ stale example strings.