AnthropicClaude APINext.jsAI Integration

Building a Streaming AI Chatbot with Claude API and Next.js App Router

A complete implementation guide for a streaming Claude AI chatbot in Next.js App Router — covering the Route Handler, a custom useChat hook, abort support, conversation history, and a production-ready chat UI.

Deepak Kaushal··11 min read

A streaming chat interface is the most common Claude API use case — and getting it right requires careful handling of streams, conversation history, abort signals, and error states. This guide builds a complete, production-ready chatbot from the Route Handler to the React UI, with no shortcuts.

Project Setup

You need Next.js App Router and the Anthropic SDK. Add your API key to .env.local — never commit it or expose it to the browser.

npm install @anthropic-ai/sdk

# .env.local
ANTHROPIC_API_KEY=sk-ant-...

The Streaming Route Handler

The Route Handler calls client.messages.stream() and pipes the text deltas into a ReadableStream. It reads the request's abort signal so the Anthropic API call is cancelled as soon as the client disconnects — preventing unnecessary token usage.

// app/api/chat/route.js
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const SYSTEM_PROMPT = `You are a helpful assistant. Be concise and accurate.
If you don't know something, say so.`;

export async function POST(req) {
  const { messages } = await req.json();

  const readable = new ReadableStream({
    async start(controller) {
      try {
        const stream = await client.messages.stream(
          {
            model: 'claude-sonnet-4-6',
            max_tokens: 2048,
            system: SYSTEM_PROMPT,
            messages,
          },
          { signal: req.signal },
        );

        for await (const chunk of stream) {
          if (
            chunk.type === 'content_block_delta' &&
            chunk.delta.type === 'text_delta'
          ) {
            controller.enqueue(
              new TextEncoder().encode(chunk.delta.text),
            );
          }
        }
      } catch (err) {
        if (err.name !== 'AbortError') {
          controller.enqueue(
            new TextEncoder().encode(`\n\n[Error: ${err.message}]`),
          );
        }
      } finally {
        controller.close();
      }
    },
  });

  return new Response(readable, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  });
}

The useChat Hook

The hook manages the full lifecycle: sending messages, consuming the stream, updating state chunk by chunk, and exposing an abort function. It also distinguishes between abort cancellations (not an error) and genuine API failures.

// hooks/useChat.js
import { useState, useRef, useCallback } from 'react';

export function useChat() {
  const [messages, setMessages] = useState([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const [error, setError] = useState(null);
  const abortRef = useRef(null);

  const sendMessage = useCallback(async (text) => {
    const userMessage = { role: 'user', content: text };
    const updatedMessages = [...messages, userMessage];

    setMessages([...updatedMessages, { role: 'assistant', content: '' }]);
    setIsStreaming(true);
    setError(null);

    abortRef.current = new AbortController();

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ messages: updatedMessages }),
        signal: abortRef.current.signal,
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let accumulated = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        accumulated += decoder.decode(value, { stream: true });
        setMessages((prev) => [
          ...prev.slice(0, -1),
          { role: 'assistant', content: accumulated },
        ]);
      }
    } catch (err) {
      if (err.name === 'AbortError') {
        // User cancelled — keep whatever streamed so far
      } else {
        setError(err.message);
        setMessages((prev) => prev.slice(0, -1)); // remove empty assistant message
      }
    } finally {
      setIsStreaming(false);
      abortRef.current = null;
    }
  }, [messages]);

  const stop = useCallback(() => {
    abortRef.current?.abort();
  }, []);

  const reset = useCallback(() => {
    setMessages([]);
    setError(null);
  }, []);

  return { messages, isStreaming, error, sendMessage, stop, reset };
}

The Chat UI Component

The ChatWindow component renders the message list, auto-scrolls to the latest message, and shows a blinking cursor while streaming. The input is disabled during streaming to prevent concurrent requests.

// components/ChatWindow.jsx
'use client';
import { useState, useRef, useEffect } from 'react';
import { useChat } from '@/hooks/useChat';

export function ChatWindow() {
  const { messages, isStreaming, error, sendMessage, stop, reset } = useChat();
  const [input, setInput] = useState('');
  const bottomRef = useRef(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!input.trim() || isStreaming) return;
    sendMessage(input.trim());
    setInput('');
  };

  return (
    <div className="flex flex-col h-[600px] bg-card border border-border rounded-2xl overflow-hidden">
      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-6 space-y-4">
        {messages.length === 0 && (
          <p className="text-center text-muted text-sm">Send a message to start.</p>
        )}
        {messages.map((msg, i) => (
          <div
            key={i}
            className={`flex ${
              msg.role === 'user' ? 'justify-end' : 'justify-start'
            }`}
          >
            <div
              className={`max-w-[80%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed ${
                msg.role === 'user'
                  ? 'bg-accent text-accent-fg rounded-br-sm'
                  : 'bg-bg border border-border text-text rounded-bl-sm'
              }`}
            >
              {msg.content}
              {isStreaming && i === messages.length - 1 && msg.role === 'assistant' && (
                <span className="inline-block w-0.5 h-4 bg-accent ml-0.5 animate-pulse" />
              )}
            </div>
          </div>
        ))}
        {error && (
          <p className="text-center text-red-400 text-xs">{error}</p>
        )}
        <div ref={bottomRef} />
      </div>

      {/* Input */}
      <form
        onSubmit={handleSubmit}
        className="border-t border-border p-4 flex gap-3"
      >
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type a message..."
          disabled={isStreaming}
          className="flex-1 bg-bg border border-border rounded-xl px-4 py-2 text-sm text-text placeholder:text-muted focus:outline-none focus:border-accent disabled:opacity-50"
        />
        {isStreaming ? (
          <button
            type="button"
            onClick={stop}
            className="px-4 py-2 bg-red-500/10 text-red-400 border border-red-500/20 rounded-xl text-sm font-medium hover:bg-red-500/20 transition-colors"
          >
            Stop
          </button>
        ) : (
          <button
            type="submit"
            disabled={!input.trim()}
            className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-40"
          >
            Send
          </button>
        )}
      </form>
    </div>
  );
}

Adding a System Prompt with Prompt Caching

If your chatbot uses a long system prompt — a knowledge base, product documentation, or a set of instructions — mark it with cache_control. Anthropic caches it for 5 minutes and reuses those tokens across requests, reducing cost by up to 90% for the cached portion. This is especially valuable for support bots or assistants with detailed context.

// app/api/chat/route.js — with cached system prompt
const stream = await client.messages.stream(
  {
    model: 'claude-sonnet-4-6',
    max_tokens: 2048,
    system: [
      {
        type: 'text',
        text: LONG_KNOWLEDGE_BASE, // large static context
        cache_control: { type: 'ephemeral' },
      },
      {
        type: 'text',
        text: 'You are a helpful assistant for this product.', // dynamic part
      },
    ],
    messages,
  },
  { signal: req.signal },
);

Rate Limiting Per User

In production, gate the Route Handler behind authentication and implement per-user rate limiting. A Redis sliding window (10 requests per minute per user) prevents abuse. The Anthropic SDK's automatic retry handles transient 529 overload errors — for 429 rate limit errors from your own limiter, return a 429 response and the client should show a 'Slow down' message rather than an error.

Persisting Conversation History

For multi-session persistence, store the messages array in a database (Supabase, PlanetScale) keyed by a conversation ID. On load, fetch the conversation history and initialise the hook's messages state. For long conversations, truncate to the last N messages before sending to the API — the 200k token context window is generous but not infinite, and you pay for every input token.

Further Reading

Frequently Asked Questions

How do I stream Claude API responses in Next.js?

Create a Route Handler (app/api/chat/route.js) that calls client.messages.stream() and returns a ReadableStream. On the client, use the Fetch API to consume the stream chunk by chunk and update React state as tokens arrive. The Anthropic SDK handles the SSE protocol — you just iterate over the stream.

How do I cancel a Claude API streaming request?

Pass an AbortController signal to your fetch call. When the user clicks Stop, call controller.abort(). In the Route Handler, the connection closes automatically when the client disconnects — no special server-side handling needed.

How do I maintain conversation history with the Claude API?

The Claude API is stateless — you send the full messages array with every request. Store messages in React state as an array of {role, content} objects. Append the user message before the API call, then append the complete assistant response when streaming finishes.

Can I use the Vercel AI SDK with Claude instead of building my own hook?

Yes. The Vercel AI SDK supports Claude via the @ai-sdk/anthropic provider and provides a useChat hook that handles streaming, history, and abort out of the box. Building your own hook gives more control over the implementation and avoids an extra dependency.

How do I add a system prompt to a Claude chatbot?

Pass a system field in the messages.create() call. The system prompt is separate from the messages array and instructs Claude on its role, tone, and constraints. Keep the system prompt static and mark it with cache_control if it's long — this caches it across requests and reduces cost.

How do I handle Claude API errors in a chat UI?

Wrap the fetch call in try/catch and maintain an error state in your hook. On error, remove the in-progress assistant message and show an error indicator in the UI. For rate limit errors (429), expose a retry button. For network errors, distinguish between user aborts (no error shown) and actual failures.

More Articles

Need help with this?

I'm available for Sharetribe Flex, Shopify, Next.js, and AI integration projects.

Get In Touch