cd /news/ai-tools/how-to-build-a-simple-ai-powered-cha… · home › topics › ai-tools › article
[ARTICLE · art-147969] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=· neutral

How to Build a Simple AI-Powered Chatbot with Next.js and Claude

A developer published a tutorial showing how to build a streaming AI chatbot with Next.js 16.4 and Claude Opus 5.5 using the Vercel AI SDK 7, requiring only an App Router route handler and a client component. The writeup notes breaking changes in AI SDK 7 — the rename of system to instructions, the drop of Node 18 and 20 support in favor of Node 22+, and the replacement of result.toUIMessageStreamResponse() with standalone helpers — plus a Next.js 16 Cache Components setting that fails the build on the default useChat call.

by read10 min views3 publishedOct 9, 2026

Originally published at jy-labs.com. Updated 2026-10-07.

You build an AI chatbot with Next.js and Claude from two files: an App Router route handler that calls streamText from the Vercel AI SDK, and a client component that renders the stream with useChat. This version uses Next.js 16.4, AI SDK 7, and Claude Opus 5.5. Every file below type-checks and passes next build as of October 7, 2026.

The 2024 version of this post used the OpenAI SDK and a hand-written fetch loop. Both are out of date. AI SDK 7 renamed system to instructions, dropped Node 20, and replaced result.toUIMessageStreamResponse() with two standalone helpers. Next.js 16 turned on Cache Components, which fails the build on the default useChat call. You want code you copy once and run, with the model, the cost, and the security tradeoffs stated up front.

You need Node.js 22 or later. AI SDK 7 sets "engines": { "node": ">=22" } in its package.json and dropped support for Node 18 and 20. I verified this build on Node 22.23.1.

Create the app with Tailwind and the App Router, then add the AI SDK packages:

npx create-next-app@latest claude-chat --ts --tailwind --eslint --app
cd claude-chat
npm install ai @ai-sdk/anthropic @ai-sdk/react

Versions this post was tested against on October 7, 2026:

next 16.4.0react 19.3.0ai 7.0.131@ai-sdk/anthropic 4.0.75@ai-sdk/react 4.0.134zod 4.6.5 (pulled in as a peer dependency, you do not import it here) All three AI SDK packages are ESM-only in version 7. If you have an older require() based config somewhere, convert it to import first.

Create an API key in the Claude Console, then add it to .env.local at the project root:

ANTHROPIC_API_KEY=sk-ant-...

The @ai-sdk/anthropic provider reads ANTHROPIC_API_KEY from the environment by default, so you never pass the key in code. Two rules keep it private:

NEXT_PUBLIC_. Next.js inlines any Add .env.local to .gitignore if create-next-app did not already do it. On Vercel, set the same variable under Project Settings, Environment Variables, and leave it unchecked for the client.

Create app/api/chat/route.ts. This is the only file that talks to Anthropic.

import { anthropic } from '@ai-sdk/anthropic';
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
  type UIMessage,
} from 'ai';

// One place to change the model. See the FAQ for the cost of each option.
const MODEL = 'claude-opus-5-5';

// Only the last N messages go to the model. Caps input tokens per request.
const MAX_HISTORY = 20;

// Allow streaming responses up to 30 seconds on Vercel.
export const maxDuration = 30;

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: anthropic(MODEL),
    instructions:
      'You are a concise assistant for a small business website. ' +
      'Answer in plain language. If you do not know, say so.',
    messages: await convertToModelMessages(messages.slice(-MAX_HISTORY)),
    maxOutputTokens: 1024,
    // Stops the Anthropic request when the browser aborts the fetch.
    abortSignal: req.signal,
    providerOptions: {
      anthropic: {
        // Chat does not need deep reasoning. 'low' cuts latency and output tokens.
        effort: 'low',
        // If Claude's safety classifiers decline a request, Anthropic re-runs it
        // on a fallback model inside the same call. The provider adds the beta header.
        fallbacks: 'default',
      },
    },
    onError: ({ error }) => {
      console.error('[chat] stream error', error);
    },
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({
      stream: result.stream,
      // The client sees this string instead of the raw error.
      onError: () => 'The assistant is unavailable right now. Try again in a moment.',
    }),
  });
}

What each piece does:

anthropic(MODEL) builds the model reference. claude-opus-5-5 is the current Opus model. The FAQ covers swapping to Sonnet or Haiku.instructions is the system prompt. AI SDK 7 renamed it from system. The old name still works with a deprecation warning. Version 7 also rejects role: "system" entries inside messages by default, so if you persist chat history, keep system text out of it.convertToModelMessages strips UI metadata from the UIMessage[] the client sends and returns the ModelMessage[] shape the model expects. It is async in version 6 and later, so await it.messages.slice(-MAX_HISTORY) bounds input tokens. Without it, a long session re-sends the whole transcript on every turn and your cost grows with conversation length.maxOutputTokens: 1024 caps the reply. Raise it if your use case needs long answers.abortSignal: req.signal cancels the Anthropic request when the user clicks Stop. Without it the server keeps generating tokens you pay for and nobody reads.effort: "low" tells Claude to spend fewer thinking tokens. Opus 5.5 defaults to medium. A website chat widget rarely needs more than low, and the difference shows up in both latency and output cost.fallbacks: "default" opts into Anthropic server-side refusal fallbacks. If a safety classifier declines a request, the API re-runs it on a fallback model in the same call. The provider adds the required beta header for you.toUIMessageStream plus createUIMessageStreamResponse replace the result.toUIMessageStreamResponse() method from version 6. The old method still works in 7 with a warning and is scheduled for removal in the next major.onError on streamText is for server logs only.maxDuration = 30 lets a Vercel function stream for up to 30 seconds. Other hosts ignore it. Replace app/page.tsx with a client component. useChat owns the message list, the request lifecycle, and the abort controller.

'use client';

import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';

export default function Chat() {
  const [input, setInput] = useState('');
  const { messages, sendMessage, status, stop, error, regenerate } = useChat({
    // A fixed id keeps the prerender deterministic under Next.js Cache Components.
    id: 'site-chat',
    transport: new DefaultChatTransport({ api: '/api/chat' }),
  });

  const busy = status === 'submitted' || status === 'streaming';

  return (
    <main className="mx-auto flex min-h-screen w-full max-w-2xl flex-col gap-4 p-6">
      <h1 className="text-xl font-semibold">Ask us anything</h1>

      <div className="flex flex-1 flex-col gap-3">
        {messages.map((message) => (
          <div
            key={message.id}
            className={
              message.role === 'user'
                ? 'self-end rounded-lg bg-blue-600 px-3 py-2 text-white'
                : 'self-start rounded-lg bg-gray-100 px-3 py-2 text-gray-900'
            }
          >
            {message.parts.map((part, index) =>
              part.type === 'text' ? (
                <p key={`${message.id}-${index}`} className="whitespace-pre-wrap">
                  {part.text}
                </p>
              ) : null,
            )}
          </div>
        ))}

        {status === 'submitted' && (
          <p className="text-sm text-gray-500">Thinking...</p>
        )}

        {error && (
          <div className="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800">
            <p>{error.message}</p>
            <button
              type="button"
              onClick={() => regenerate()}
              className="mt-2 underline"
            >
              Retry
            </button>
          </div>
        )}
      </div>

      <form
        onSubmit={(event) => {
          event.preventDefault();
          const text = input.trim();
          if (!text || busy) return;
          sendMessage({ text });
          setInput('');
        }}
        className="flex gap-2"
      >
        <input
          value={input}
          onChange={(event) => setInput(event.target.value)}
          placeholder="Type a question"
          className="flex-1 rounded-lg border border-gray-300 px-3 py-2"
          disabled={busy}
        />
        {busy ? (
          <button
            type="button"
            onClick={() => stop()}
            className="rounded-lg border border-gray-300 px-4 py-2"
          >
            Stop
          </button>
        ) : (
          <button
            type="submit"
            className="rounded-lg bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
            disabled={!input.trim()}
          >
            Send
          </button>
        )}
      </form>
    </main>
  );
}

Notes on the parts people trip on:

message.parts replaced message.content in AI SDK 5. A message is an array of typed parts. This UI renders only text parts. If you add tools later, you render tool-* parts in the same switch. status is one of submitted, streaming, ready, or error. The form disables itself while busy and swaps the Send button for Stop.stop() aborts the fetch. Combined with abortSignal in the route handler, the Anthropic request ends too.regenerate() give you a retry path. The message you see in error.message is the string your server returned from id: "site-chat" is required when Cache Components are on. create-next-app for Next.js 16.4 writes cacheComponents: true into next.config.ts. Without it, useChat generates a random chat id during server-side prerender, and next build fails with "Next.js encountered the unstable value Math.random() in a Client Component." A fixed id makes the prerender deterministic. Wrapping the component in Suspense is the other documented fix.DefaultChatTransport is where you add headers or extra body fields later, for example a session id for rate limiting.

npm run dev

Open http://localhost:3000, type a question, and watch the reply arrive token by token. Three checks worth doing before you move on:

/api/chat response. It should be a streamed response with content type text/event-stream, and you should see the chunks arrive over time rather than one blob.ANTHROPIC_API_KEY to a bad value and send a message. The browser shows your To confirm the type contract without a key, run npx tsc --noEmit and npm run build. Both passed on the exact versions listed in Step 1.

The two files above are a complete chatbot. They are not a complete product. Four things to add, in order:

result.usage on the server so you see tokens per conversation. That number, times the prices in the FAQ, is your bill. If you would rather have this scoped and built for your business, book a $350 AI strategy session. The fee is credited in full toward a build.

A chatbot message costs about $0.012 on Claude Opus 5.5, $0.006 on Sonnet 5.5, and $0.0003 on Haiku 5.5, assuming roughly 1,500 input tokens (system prompt plus a 20-message history) and 300 output tokens. Anthropic list prices on October 7, 2026: Opus 5.5 is $4 per million input tokens and $20 per million output. Sonnet 5.5 is $2 and $10. Haiku 5.5 is $0.10 and $0.50 for prompts under 100,000 tokens. Per 1,000 messages that is about $12, $6, and $0.30. Cache reads on Opus 5.5 and Sonnet 5.5 are 5 percent of the input price, so a stable system prompt lowers the input side further. Log result.usage to replace these estimates with your own numbers.

Keep the Anthropic API key server-side by storing it as ANTHROPIC_API_KEY in .env.local and only importing @ai-sdk/anthropic from a route handler or server component. Never prefix the variable with NEXT_PUBLIC_, because Next.js inlines those into the client bundle. The client component in this tutorial imports @ai-sdk/react and ai only, and it calls your /api/chat route, so the key never leaves the server. On Vercel, add the variable in Project Settings and leave it as a server-only variable.

Swap the Claude model by changing the MODEL constant in app/api/chat/route.ts to another model id the @ai-sdk/anthropic provider accepts: claude-opus-5-5, claude-sonnet-5-5, or claude-haiku-5-5 are the current options in each tier. Use the bare id with no date suffix. Sonnet 5.5 costs half of Opus 5.5 per token and Haiku 5.5 costs a fortieth. Test replies after a swap, because effort levels and default behavior differ between tiers. For a website chat widget, Sonnet 5.5 or Haiku 5.5 at low effort is the usual place to land after you have measured quality on Opus.

This chatbot requires Node.js 22 or later, because the ai package version 7 declares engines node 22 and up and the AI SDK 7 migration guide states that Node 18 and 20 are no longer supported. Next.js 16.4 requires Node 20.9 or later, so the AI SDK is the stricter constraint. This build was verified on Node 22.23.1. Set "engines": { "node": ">=22" } in package.json so a teammate on Node 20 gets a clear error instead of a confusing stack trace.

next build fails with the Math.random() error because create-next-app for Next.js 16.4 writes cacheComponents: true into next.config.ts, and Cache Components prerender client components on the server and rejects any value the prerender cannot reproduce. useChat generates a random chat id when you do not pass one. The fix is to pass a fixed id, for example useChat({ id: "site-chat", ... }). Wrapping the chat component in a Suspense boundary from a server component parent is the other fix documented by Next.js.

This chatbot cannot answer questions about your business on its own, because Claude only knows what is in the system prompt and the conversation. For hours, pricing, and policies you can paste a short block of facts into the instructions string. For anything larger than a page or two, you need retrieval: your documents indexed and the relevant passages fetched into the prompt on each turn. That is a RAG agent, and it is a separate build from the one in this post.

JY Labs builds AI automation for businesses: RAG agents, lead generation, content automation, and voice agents. Read the original post and more at jy-labs.com.

── more in #ai-tools 4 stories · sorted by recency
── more on @next.js 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-build-a-simpl…] indexed:0 read:10min 2026-10-09 · —