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

> Source: <https://dev.to/jyeg/how-to-build-a-simple-ai-powered-chatbot-with-nextjs-and-claude-5gck>
> Published: 2026-10-09 01:10:31+00:00

*Originally published at [jy-labs.com](https://jy-labs.com/blog/tutorial-nextjs-claude-chatbot). 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.0`react` 19.3.0`ai` 7.0.131`@ai-sdk/anthropic` 4.0.75`@ai-sdk/react` 4.0.134`zod` 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.

``` js
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.

``` js
'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](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](https://dev.to/contact). 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](https://jy-labs.com/blog/tutorial-nextjs-claude-chatbot).*
