DEV Community

John Yegs
John Yegs

Posted on Originally published at jy-labs.com

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

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 problem

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.

The approach

Step 1: Scaffold the project and install three packages

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
Enter fullscreen mode Exit fullscreen mode

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.

Step 2: Put the API key where the browser cannot see it

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

ANTHROPIC_API_KEY=sk-ant-...
Enter fullscreen mode Exit fullscreen mode

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:

  1. Never prefix it with NEXT_PUBLIC_. Next.js inlines any NEXT_PUBLIC_ variable into the client bundle.
  2. Only import @ai-sdk/anthropic from server code. In this tutorial the only import lives in the route handler. The page component imports @ai-sdk/react and ai, neither of which touches the key.

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.

Step 3: Write the route handler

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.',
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

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.
  • The onError on toUIMessageStream controls what text the browser sees. The SDK masks errors by default and sends the literal string "An error occurred." Return your own string there. The onError on streamText is for server logs only.
  • maxDuration = 30 lets a Vercel function stream for up to 30 seconds. Other hosts ignore it.

Step 4: Write the chat UI

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.
  • error and regenerate() give you a retry path. The message you see in error.message is the string your server returned from onError.
  • 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.

Step 5: Run it and confirm the stream

npm run dev
Enter fullscreen mode Exit fullscreen mode

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

  1. Open the Network tab and look at the /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.
  2. Click Stop mid-response. The stream ends and the server log shows no further output.
  3. Set ANTHROPIC_API_KEY to a bad value and send a message. The browser shows your onError string, and the Retry button calls regenerate().

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.

Step 6: What to add before this goes on a real site

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

  1. Rate limiting. Anyone who finds /api/chat can call it with your key behind it. Put a per-IP or per-session limit in front of the route. Upstash Ratelimit works on serverless hosts without a persistent connection.
  2. A real system prompt. The instructions string above is a placeholder. Write down what the bot does, what it refuses, and how it hands off to a human. Keep it stable so Anthropic prompt caching has a prefix to reuse.
  3. Logging. Log result.usage on the server so you see tokens per conversation. That number, times the prices in the FAQ, is your bill.
  4. Your own data. This bot answers from Claude general knowledge. It does not know your hours, your pricing, or your policies. For that you need retrieval over your documents. RAG agents vs. FAQ chatbots explains the difference, and the RAG agents service page covers what a build looks like.

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.

Results

  • Files you write: 2 (One route handler, one client component. Plus one line in .env.local.)
  • Verified build: Oct 7, 2026 (tsc --noEmit and next build pass on Next.js 16.4.0, ai 7.0.131, Node 22.23.1.)
  • Time to a streaming reply: Under 1 hour (From an empty directory to a working chat on localhost, API key in hand.)

FAQ

How much does each chatbot message cost with Claude?

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.

How do I keep the Anthropic API key server-side in Next.js?

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.

How do I swap the Claude model in this chatbot?

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.

Which Node.js version does this chatbot require?

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.

Why does next build fail with "unstable value Math.random() in a Client Component"?

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.

Can this chatbot answer questions about my own business?

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.

Top comments (0)