{"slug": "how-to-build-a-simple-ai-powered-chatbot-with-next-js-and-claude", "title": "How to Build a Simple AI-Powered Chatbot with Next.js and Claude", "summary": "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.", "body_md": "*Originally published at [jy-labs.com](https://jy-labs.com/blog/tutorial-nextjs-claude-chatbot). Updated 2026-10-07.*\n\nYou 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.\n\nThe 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.\n\nYou 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.\n\nCreate the app with Tailwind and the App Router, then add the AI SDK packages:\n\n```\nnpx create-next-app@latest claude-chat --ts --tailwind --eslint --app\ncd claude-chat\nnpm install ai @ai-sdk/anthropic @ai-sdk/react\n```\n\nVersions this post was tested against on October 7, 2026:\n\n`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)\nAll three AI SDK packages are ESM-only in version 7. If you have an older `require()` based config somewhere, convert it to `import` first.\n\nCreate an API key in the Claude Console, then add it to `.env.local` at the project root:\n\n```\nANTHROPIC_API_KEY=sk-ant-...\n```\n\nThe `@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:\n\n`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.\n\nCreate `app/api/chat/route.ts`. This is the only file that talks to Anthropic.\n\n``` js\nimport { anthropic } from '@ai-sdk/anthropic';\nimport {\n  convertToModelMessages,\n  createUIMessageStreamResponse,\n  streamText,\n  toUIMessageStream,\n  type UIMessage,\n} from 'ai';\n\n// One place to change the model. See the FAQ for the cost of each option.\nconst MODEL = 'claude-opus-5-5';\n\n// Only the last N messages go to the model. Caps input tokens per request.\nconst MAX_HISTORY = 20;\n\n// Allow streaming responses up to 30 seconds on Vercel.\nexport const maxDuration = 30;\n\nexport async function POST(req: Request) {\n  const { messages }: { messages: UIMessage[] } = await req.json();\n\n  const result = streamText({\n    model: anthropic(MODEL),\n    instructions:\n      'You are a concise assistant for a small business website. ' +\n      'Answer in plain language. If you do not know, say so.',\n    messages: await convertToModelMessages(messages.slice(-MAX_HISTORY)),\n    maxOutputTokens: 1024,\n    // Stops the Anthropic request when the browser aborts the fetch.\n    abortSignal: req.signal,\n    providerOptions: {\n      anthropic: {\n        // Chat does not need deep reasoning. 'low' cuts latency and output tokens.\n        effort: 'low',\n        // If Claude's safety classifiers decline a request, Anthropic re-runs it\n        // on a fallback model inside the same call. The provider adds the beta header.\n        fallbacks: 'default',\n      },\n    },\n    onError: ({ error }) => {\n      console.error('[chat] stream error', error);\n    },\n  });\n\n  return createUIMessageStreamResponse({\n    stream: toUIMessageStream({\n      stream: result.stream,\n      // The client sees this string instead of the raw error.\n      onError: () => 'The assistant is unavailable right now. Try again in a moment.',\n    }),\n  });\n}\n```\n\nWhat each piece does:\n\n`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.\nReplace `app/page.tsx` with a client component. `useChat` owns the message list, the request lifecycle, and the abort controller.\n\n``` js\n'use client';\n\nimport { useChat } from '@ai-sdk/react';\nimport { DefaultChatTransport } from 'ai';\nimport { useState } from 'react';\n\nexport default function Chat() {\n  const [input, setInput] = useState('');\n  const { messages, sendMessage, status, stop, error, regenerate } = useChat({\n    // A fixed id keeps the prerender deterministic under Next.js Cache Components.\n    id: 'site-chat',\n    transport: new DefaultChatTransport({ api: '/api/chat' }),\n  });\n\n  const busy = status === 'submitted' || status === 'streaming';\n\n  return (\n    <main className=\"mx-auto flex min-h-screen w-full max-w-2xl flex-col gap-4 p-6\">\n      <h1 className=\"text-xl font-semibold\">Ask us anything</h1>\n\n      <div className=\"flex flex-1 flex-col gap-3\">\n        {messages.map((message) => (\n          <div\n            key={message.id}\n            className={\n              message.role === 'user'\n                ? 'self-end rounded-lg bg-blue-600 px-3 py-2 text-white'\n                : 'self-start rounded-lg bg-gray-100 px-3 py-2 text-gray-900'\n            }\n          >\n            {message.parts.map((part, index) =>\n              part.type === 'text' ? (\n                <p key={`${message.id}-${index}`} className=\"whitespace-pre-wrap\">\n                  {part.text}\n                </p>\n              ) : null,\n            )}\n          </div>\n        ))}\n\n        {status === 'submitted' && (\n          <p className=\"text-sm text-gray-500\">Thinking...</p>\n        )}\n\n        {error && (\n          <div className=\"rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800\">\n            <p>{error.message}</p>\n            <button\n              type=\"button\"\n              onClick={() => regenerate()}\n              className=\"mt-2 underline\"\n            >\n              Retry\n            </button>\n          </div>\n        )}\n      </div>\n\n      <form\n        onSubmit={(event) => {\n          event.preventDefault();\n          const text = input.trim();\n          if (!text || busy) return;\n          sendMessage({ text });\n          setInput('');\n        }}\n        className=\"flex gap-2\"\n      >\n        <input\n          value={input}\n          onChange={(event) => setInput(event.target.value)}\n          placeholder=\"Type a question\"\n          className=\"flex-1 rounded-lg border border-gray-300 px-3 py-2\"\n          disabled={busy}\n        />\n        {busy ? (\n          <button\n            type=\"button\"\n            onClick={() => stop()}\n            className=\"rounded-lg border border-gray-300 px-4 py-2\"\n          >\n            Stop\n          </button>\n        ) : (\n          <button\n            type=\"submit\"\n            className=\"rounded-lg bg-blue-600 px-4 py-2 text-white disabled:opacity-50\"\n            disabled={!input.trim()}\n          >\n            Send\n          </button>\n        )}\n      </form>\n    </main>\n  );\n}\n```\n\nNotes on the parts people trip on:\n\n`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.\n\n```\nnpm run dev\n```\n\nOpen [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:\n\n`/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.\n\nThe two files above are a complete chatbot. They are not a complete product. Four things to add, in order:\n\n`result.usage` on the server so you see tokens per conversation. That number, times the prices in the FAQ, is your bill.\nIf 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.\n\nA 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.\n\nKeep 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.\n\nSwap 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.\n\nThis 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.\n\nnext 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.\n\nThis 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.\n\n*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).*", "url": "https://wpnews.pro/news/how-to-build-a-simple-ai-powered-chatbot-with-next-js-and-claude", "canonical_source": "https://dev.to/jyeg/how-to-build-a-simple-ai-powered-chatbot-with-nextjs-and-claude-5gck", "published_at": "2026-10-09 01:10:31+00:00", "updated_at": "2026-10-09 01:18:07.226544+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "large-language-models", "generative-ai"], "entities": ["Next.js", "Claude Opus 5.5", "Vercel AI SDK", "Anthropic", "React", "Node.js", "Vercel", "jy-labs.com"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-build-a-simple-ai-powered-chatbot-with-next-js-and-claude", "markdown": "https://wpnews.pro/news/how-to-build-a-simple-ai-powered-chatbot-with-next-js-and-claude.md", "text": "https://wpnews.pro/news/how-to-build-a-simple-ai-powered-chatbot-with-next-js-and-claude.txt", "jsonld": "https://wpnews.pro/news/how-to-build-a-simple-ai-powered-chatbot-with-next-js-and-claude.jsonld"}}