AI7 min read
Build an AI Chatbot in Next.js with Streaming (AI SDK 7)
Build an AI chatbot in Next.js with streaming responses using AI SDK 7: a streamText route handler, useChat on the client, tools, persistence, any provider.
To build an AI chatbot in Next.js with streaming responses, add a route handler at app/api/chat/route.ts that calls streamText from the ai package and returns the result as a UI message stream, then render the conversation on the client with the useChat hook from @ai-sdk/react. Tokens appear as the model generates them, the user can stop a response mid-stream, and you can swap the model provider without touching the UI.
I built the AI assistant "Cury" and a ChatBot product for Mercury 360, a Next.js HR platform, so the patterns here come from shipping chat to real users. The code targets AI SDK 7, the current major version. The SDK changed a lot between v4, v5 and v7, so if a tutorial uses handleSubmit or message.content, it is out of date.
Key takeaways
- The server uses
streamTextplustoUIMessageStreamandcreateUIMessageStreamResponse(the v7 replacements fortoUIMessageStreamResponse). - The client uses
useChat, which no longer manages input state. You keep the input inuseStateand callsendMessage({ text }). - Messages are rendered from
message.parts, not a singlecontentstring, so text, tool calls and reasoning all have a place. - Provider-agnostic means a model string like
"anthropic/claude-sonnet-5.5"through the AI Gateway, or a provider package if you prefer direct API keys. - Production needs auth, rate limits, persistence and a
maxDurationthat fits your longest answer.
What changed in the AI SDK (v4 to v7)
Most broken chatbot code online comes from version drift. Here is the short version of what moved, based on the official migration guides:
| Area | v4 | v5 / v6 | v7 (current) |
|---|---|---|---|
| Client input | input, handleInputChange, handleSubmit from useChat |
You own input state, call sendMessage({ text }) |
Same as v5 |
| Message shape | message.content string |
message.parts array (UIMessage) |
Same |
| Endpoint config | useChat({ api }) |
transport: new DefaultChatTransport({ api }) |
Same |
| Server response | toDataStreamResponse() |
result.toUIMessageStreamResponse() |
createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) }); the old helper is deprecated |
| Multi-step tools | maxSteps |
stopWhen: stepCountIs(n) |
stopWhen: isStepCount(n) |
| Callbacks | onFinish |
onFinish, onStepFinish |
onEnd, onStepEnd (old names are deprecated aliases) |
| Runtime | Node 18+ | Node 18+ | Node 22+, ESM only |
The current docs also name the system prompt option instructions and await the convertToModelMessages call, so follow that in new code. If you are upgrading an existing app, read the AI SDK migration guides in order rather than jumping straight to v7.
Project setup
Start from a Next.js App Router project, then install the SDK and Zod (used for tool schemas):
npx create-next-app@latest my-chatbot
cd my-chatbot
npm install ai @ai-sdk/react zodCheck that you are on Node.js 22 or newer (node -v), since AI SDK 7 dropped Node 18 and 20.
For the model, you have two provider-agnostic options:
- AI Gateway model strings. Set
AI_GATEWAY_API_KEYin.env.localand pass a string like"anthropic/claude-sonnet-5.5"or"openai/<model-id>"as the model. Switching providers is a string change. - Provider packages. Install
@ai-sdk/openai,@ai-sdk/anthropicor@ai-sdk/google, set that provider's API key and pass e.g.openai("<model-id>"). Same interface, direct billing with the provider.
I like keeping the model ID in an environment variable so staging can test a new model without a deploy.
The streaming route handler
// app/api/chat/route.ts
import {
convertToModelMessages,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
type UIMessage,
} from "ai";
// Allow streaming responses up to 30 seconds on serverless hosts
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: process.env.CHAT_MODEL ?? "anthropic/claude-sonnet-5.5",
instructions:
"You are a concise assistant for an HR platform. If you are not sure, say so.",
messages: await convertToModelMessages(messages),
abortSignal: req.signal,
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}What each piece does:
UIMessage[]is what the client sends: messages withid,roleandparts.convertToModelMessagesstrips UI-only data and turns them into the format models expect.streamTextstarts generation and returns immediately; nothing is buffered until the end.toUIMessageStream+createUIMessageStreamResponseturn the raw event stream into the protocoluseChatunderstands and wrap it in a streamingResponse.abortSignal: req.signalstops generation (and token spend) when the user hits Stop or closes the tab.
The chat UI with useChat
// app/page.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { useState } from "react";
export default function ChatPage() {
const [input, setInput] = useState("");
const { messages, sendMessage, status, stop, error, regenerate } = useChat();
const busy = status === "submitted" || status === "streaming";
return (
<main className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
{messages.map((message) => (
<div key={message.id} className="whitespace-pre-wrap">
<strong>{message.role === "user" ? "You" : "Assistant"}: </strong>
{message.parts.map((part, i) =>
part.type === "text" ? <span key={`${message.id}-${i}`}>{part.text}</span> : null
)}
</div>
))}
{error && (
<div role="alert">
Something went wrong.{" "}
<button type="button" onClick={() => regenerate()}>
Retry
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim() || busy) return;
sendMessage({ text: input });
setInput("");
}}
className="flex gap-2"
>
<input
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
placeholder="Ask something..."
className="flex-1 rounded border p-2"
/>
{busy ? (
<button type="button" onClick={() => stop()}>
Stop
</button>
) : (
<button type="submit">Send</button>
)}
</form>
</main>
);
}status moves through "submitted" (request sent, no tokens yet), "streaming", "ready" and "error". Showing a typing indicator during "submitted" makes the app feel much faster, because the first token can take a moment.
By default useChat posts to /api/chat. To change the endpoint or send extra data, pass a transport:
import { DefaultChatTransport } from "ai";
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({
api: "/api/support-chat",
body: { workspaceId: "acme" },
}),
});Adding tools (function calling)
Tools let the model call your code, for example to look up a leave balance. The model decides when to call a tool; your execute function runs on the server.
import { isStepCount, tool } from "ai";
import { z } from "zod";
const result = streamText({
model: process.env.CHAT_MODEL ?? "anthropic/claude-sonnet-5.5",
messages: await convertToModelMessages(messages),
stopWhen: isStepCount(5),
tools: {
getLeaveBalance: tool({
description: "Get the remaining leave days for the signed-in employee",
inputSchema: z.object({
leaveType: z.enum(["annual", "sick"]),
}),
execute: async ({ leaveType }) => {
const userId = await getUserIdFromSession(); // never trust IDs from the model
return { leaveType, days: await db.leaveBalance(userId, leaveType) };
},
}),
},
});stopWhen: isStepCount(5) lets the model call a tool, read the result and then answer, instead of stopping after the tool call. On the client, tool calls show up as parts with type: "tool-getLeaveBalance", so you can render a small "Checking your balance..." card while it runs.
The comment in execute matters. The model's arguments are user-influenced input. Resolve identity from the session, never from what the model passes in.
Persisting conversations
To save chats, give toUIMessageStream the original messages and an onEnd callback. It receives the full updated message list once streaming finishes.
const { messages, id }: { messages: UIMessage[]; id: string } = await req.json();
const result = streamText({ /* ...same as before */ });
// Keep generating (and saving) even if the client disconnects mid-stream
result.consumeStream();
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
originalMessages: messages,
onEnd: async ({ messages }) => {
await saveChat({ chatId: id, messages });
},
}),
});Store UIMessage arrays rather than model messages, because that is what useChat needs to restore a conversation. For long chats, the docs also show sending only the last message from the client (via prepareSendMessagesRequest on the transport) and loading history on the server, which keeps request bodies small as conversations grow.
Production checklist
Getting tokens on screen is the demo. These are what make it shippable:
- Authentication. Check the session in the route handler before calling the model. An open
/api/chatis an open credit card. See my Next.js authentication guide. - Rate limiting. Limit per user and per IP, and cap
maxOutputTokens. maxDuration. Serverless platforms kill long requests. Set it to cover your slowest realistic answer.- Markdown rendering. Models return Markdown. Render it with a sanitising Markdown component and never inject raw HTML.
- Prompt injection. Anything from users, documents or tools can contain instructions. Keep tool permissions narrow.
- Observability. Log token usage per request (
result.usagetotals all steps in v7) so you can see cost per feature. - Server Actions vs route handlers.
useChatexpects an HTTP endpoint, so a route handler is the natural fit. Use Server Actions for the surrounding CRUD such as renaming or deleting chats.
Common mistakes
- Copying v4 code.
const { input, handleSubmit } = useChat()returns undefined values in v5 and later. - Rendering
message.content. It does not exist onUIMessage. Map overparts. - Forgetting
awaitonreq.json()orconvertToModelMessages. You get a confusing type error or an empty prompt. - Using CommonJS. AI SDK 7 is ESM-only.
require("ai")fails. - Putting the API key in a client component. Model calls belong on the server. If the key starts with
NEXT_PUBLIC_, it is already leaked. - Not handling Stop. Without
abortSignal, the model keeps generating (and billing) after the user leaves.
FAQ
How do I stream an AI response in Next.js?
Create a POST route handler that calls streamText from the ai package and returns createUIMessageStreamResponse with toUIMessageStream({ stream: result.stream }). On the client, the useChat hook from @ai-sdk/react reads that stream and updates messages as tokens arrive.
Which AI SDK version should I use?
Use the current major, AI SDK 7, for new projects. It requires Node.js 22 or newer and is ESM-only. Code written for v4 (input, handleSubmit, message.content) will not work without migration.
Can I switch between OpenAI, Anthropic and Google without rewriting my chatbot?
Yes. The AI SDK uses one interface for all providers, so switching is usually a change to the model string (with the AI Gateway) or the provider import. Prompts and tool behaviour may still need tuning per model.
Do I need an edge runtime for streaming?
No. Streaming works in the default Node.js runtime for Next.js route handlers. Set maxDuration high enough for long responses on serverless hosts.
Wrapping up
A streaming Next.js chatbot is now a small amount of code: one route handler with streamText, one client component with useChat, and parts-based rendering. The real work is around it: auth, limits, persistence and tools that respect user permissions. Pin your ai version, read the migration guide before upgrading, and keep the model ID in config so you can switch providers when a better one ships.
- #ai
- #nextjs
- #ai-sdk
- #streaming
- #react
Keep reading
AI8 min read
AI Coding Assistants for React Developers: A Workflow
A practical AI coding assistant workflow for React developers: how to prompt, review AI-generated code, test it, and where AI helps or quietly hurts.
Read articleReact9 min read
React Server Components Explained: A Clear Mental Model
React Server Components explained: how server and client components differ, what 'use client' really does, serialization rules and mistakes to avoid in Next.js.
Read articleReact8 min read
Vite vs Next.js vs React Router: Choosing a React Stack
Vite vs Next.js vs React Router framework mode in 2026: compare rendering, routing, data loading, SEO and hosting to pick the right React stack for you.
Read article