Skip to content

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 streamText plus toUIMessageStream and createUIMessageStreamResponse (the v7 replacements for toUIMessageStreamResponse).
  • The client uses useChat, which no longer manages input state. You keep the input in useState and call sendMessage({ text }).
  • Messages are rendered from message.parts, not a single content string, 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 maxDuration that 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):

Terminal
npx create-next-app@latest my-chatbot
cd my-chatbot
npm install ai @ai-sdk/react zod

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

  1. AI Gateway model strings. Set AI_GATEWAY_API_KEY in .env.local and pass a string like "anthropic/claude-sonnet-5.5" or "openai/<model-id>" as the model. Switching providers is a string change.
  2. Provider packages. Install @ai-sdk/openai, @ai-sdk/anthropic or @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

TypeScript
// 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 with id, role and parts.
  • convertToModelMessages strips UI-only data and turns them into the format models expect.
  • streamText starts generation and returns immediately; nothing is buffered until the end.
  • toUIMessageStream + createUIMessageStreamResponse turn the raw event stream into the protocol useChat understands and wrap it in a streaming Response.
  • abortSignal: req.signal stops generation (and token spend) when the user hits Stop or closes the tab.

The chat UI with useChat

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

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

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

TypeScript
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/chat is 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.usage totals all steps in v7) so you can see cost per feature.
  • Server Actions vs route handlers. useChat expects 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

  1. Copying v4 code. const { input, handleSubmit } = useChat() returns undefined values in v5 and later.
  2. Rendering message.content. It does not exist on UIMessage. Map over parts.
  3. Forgetting await on req.json() or convertToModelMessages. You get a confusing type error or an empty prompt.
  4. Using CommonJS. AI SDK 7 is ESM-only. require("ai") fails.
  5. 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.
  6. 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