AI chatbots have moved from novelty to essential product feature. Next.js combined with the Anthropic Claude API gives you server-side streaming, type-safe API routes, and React Server Components — everything you need to build a responsive chat interface that streams tokens in real time. This guide walks you through building a complete chatbot with conversation history, streaming responses, and a clean message UI.
Prerequisites
- →Node.js 20+
Required for Next.js 15 App Router and server-side streaming support.
- →Anthropic API Key
Sign up for an Anthropic account and generate an API key. The free tier gives you enough credits to build and test.
- →TypeScript Fundamentals
You should be comfortable with TypeScript interfaces, async/await, and generic types.
- →React Hooks Experience
Familiarity with useState, useEffect, useRef, and custom hooks for managing chat state.
Scaffold the Next.js Project and Install Dependencies
Create a new Next.js app and install the Anthropic SDK. The official @anthropic-ai/sdk package provides typed methods for all Claude API endpoints, including streaming. We also add a few utilities for managing chat state.
npx create-next-app@latest ai-chatbot \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
cd ai-chatbot
npm install @anthropic-ai/sdk
npm install nanoidTip: The @anthropic-ai/sdk package handles authentication, retries, and streaming natively — no need for a generic HTTP client.
Tip: nanoid generates compact unique IDs for messages, which are lighter than UUIDs for client-side state.
Define Chat Types and Message Store
Create TypeScript types for your chat messages and a simple store to manage conversation state. Each message has a role (user or assistant), content, and a unique ID. Keeping the types separate makes it easy to serialize conversations to localStorage or a database later.
// src/lib/chat.ts
import { nanoid } from "nanoid";
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
createdAt: Date;
}
export function createMessage(
role: "user" | "assistant",
content: string
): Message {
return {
id: nanoid(),
role,
content,
createdAt: new Date(),
};
}
export interface ChatState {
messages: Message[];
isStreaming: boolean;
}Tip: Keep the Message interface flat and serializable — avoid storing React elements or functions in state.
Tip: The isStreaming flag prevents users from sending new messages while the assistant is still responding.
Build the Streaming API Route with Claude
Create a POST route handler that receives the conversation history, sends it to the Claude API with streaming enabled, and pipes the response back as a ReadableStream. This gives the user real-time token-by-token output instead of waiting for the full response. The route validates the input and sets a system prompt that defines the assistant's behavior.
// src/app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
import { NextRequest } from "next/server";
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
});
export async function POST(req: NextRequest) {
const { messages } = await req.json();
if (!Array.isArray(messages) || messages.length === 0) {
return new Response("Messages array is required", { status: 400 });
}
const stream = anthropic.messages.stream({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
system:
"You are a helpful assistant. Be concise and direct. Use markdown for code blocks and formatting.",
messages: messages.map((m: { role: string; content: string }) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
});
const readableStream = new ReadableStream({
async start(controller) {
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
controller.enqueue(
new TextEncoder().encode(event.delta.text)
);
}
}
controller.close();
},
});
return new Response(readableStream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
});
}Tip: Always set max_tokens to prevent runaway responses that consume your API credits.
Tip: Use claude-sonnet-4-20250514 for the best balance of speed and quality in a chatbot. Switch to claude-opus-4-20250514 only if you need deeper reasoning.
Build the Chat Input Component
Create a chat input with auto-resizing textarea and submit handling. The component disables the input while the assistant is streaming and supports both Enter to send and Shift+Enter for newlines. A clean input component is critical for chat UX.
// src/components/ChatInput.tsx
"use client";
import { useRef, useCallback, type KeyboardEvent } from "react";
interface ChatInputProps {
onSend: (message: string) => void;
isStreaming: boolean;
}
export default function ChatInput({ onSend, isStreaming }: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = useCallback(() => {
const value = textareaRef.current?.value.trim();
if (!value || isStreaming) return;
onSend(value);
if (textareaRef.current) {
textareaRef.current.value = "";
textareaRef.current.style.height = "auto";
}
}, [onSend, isStreaming]);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="border-t border-gray-800 bg-gray-950 p-4">
<div className="mx-auto flex max-w-3xl items-end gap-3">
<textarea
ref={textareaRef}
onKeyDown={handleKeyDown}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
}}
placeholder="Type a message..."
rows={1}
disabled={isStreaming}
className="flex-1 resize-none rounded-lg border border-gray-700 bg-gray-900 px-4 py-3 text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none disabled:opacity-50"
/>
<button
onClick={handleSubmit}
disabled={isStreaming}
className="rounded-lg bg-blue-600 px-4 py-3 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
>
Send
</button>
</div>
</div>
);
}Tip: Use an uncontrolled textarea with useRef instead of useState — it avoids re-rendering the entire chat on every keystroke.
Tip: Set max-height on the textarea with CSS to prevent it from growing taller than 200px on long messages.
Build the Message List with Streaming Display
Create the message list component that renders the conversation and handles streaming text. While the assistant is responding, the latest message updates in real time as tokens arrive. The component auto-scrolls to the bottom on new messages and shows a subtle typing indicator during streaming.
// src/components/MessageList.tsx
"use client";
import { useEffect, useRef } from "react";
import type { Message } from "@/lib/chat";
interface MessageListProps {
messages: Message[];
isStreaming: boolean;
}
export default function MessageList({ messages, isStreaming }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
return (
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="mx-auto max-w-3xl space-y-6">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
message.role === "user"
? "bg-blue-600 text-white"
: "bg-gray-800 text-gray-100"
}`}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{message.content}
</p>
</div>
</div>
))}
{isStreaming && messages[messages.length - 1]?.role === "user" && (
<div className="flex justify-start">
<div className="rounded-2xl bg-gray-800 px-4 py-3">
<div className="flex gap-1">
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-500" />
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-500 [animation-delay:0.1s]" />
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-500 [animation-delay:0.2s]" />
</div>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
</div>
);
}Tip: Use smooth scrolling for auto-scroll but switch to instant scrolling if the user has scrolled up — this prevents yanking them back down while reading older messages.
Tip: Limit max-w-[80%] on message bubbles so long messages don't stretch edge to edge.
Wire Up the Chat Page with Streaming Logic
Connect all components in the main chat page. The useChat hook manages sending messages, reading the streaming response, and appending tokens to the assistant's message in real time. The page handles the full lifecycle: user types, message sends to the API, tokens stream back, and the conversation updates.
// src/app/page.tsx
"use client";
import { useState, useCallback } from "react";
import { createMessage, type Message } from "@/lib/chat";
import MessageList from "@/components/MessageList";
import ChatInput from "@/components/ChatInput";
export default function ChatPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const sendMessage = useCallback(
async (content: string) => {
const userMessage = createMessage("user", content);
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setIsStreaming(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: updatedMessages.map((m) => ({
role: m.role,
content: m.content,
})),
}),
});
if (!response.ok) throw new Error("Failed to send message");
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const assistantMessage = createMessage("assistant", "");
setMessages((prev) => [...prev, assistantMessage]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
assistantMessage.content += text;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: assistantMessage.content }
: m
)
);
}
} catch (error) {
console.error("Chat error:", error);
setMessages((prev) => [
...prev,
createMessage("assistant", "Sorry, something went wrong. Please try again."),
]);
} finally {
setIsStreaming(false);
}
},
[messages]
);
return (
<main className="flex h-screen flex-col bg-gray-950">
<header className="border-b border-gray-800 px-4 py-3">
<h1 className="text-center text-lg font-semibold text-white">
AI Chat
</h1>
</header>
<MessageList messages={messages} isStreaming={isStreaming} />
<ChatInput onSend={sendMessage} isStreaming={isStreaming} />
</main>
);
}Tip: Store messages in localStorage on every update so conversations survive page refreshes.
Tip: Add a 'New Chat' button that clears messages and starts a fresh conversation.
Add Conversation Persistence with localStorage
Save chat history to localStorage so conversations survive page refreshes and browser restarts. Load saved messages on mount and update storage on every new message. This is a simple approach that works well for single-user chatbots without requiring a database.
// src/hooks/usePersistedChat.ts
import { useState, useEffect, useCallback } from "react";
import type { Message } from "@/lib/chat";
const STORAGE_KEY = "ai-chat-messages";
export function usePersistedChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed: Message[] = JSON.parse(stored);
setMessages(
parsed.map((m) => ({ ...m, createdAt: new Date(m.createdAt) }))
);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
setIsHydrated(true);
}, []);
const updateMessages = useCallback(
(updater: Message[] | ((prev: Message[]) => Message[])) => {
setMessages((prev) => {
const next =
typeof updater === "function" ? updater(prev) : updater;
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
return next;
});
},
[]
);
const clearMessages = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setMessages([]);
}, []);
return { messages, setMessages: updateMessages, clearMessages, isHydrated };
}Tip: Wait for isHydrated before rendering messages to avoid a flash of empty state on page load.
Tip: Set a maximum number of stored conversations to prevent localStorage from growing unbounded.
Deploy and Set Environment Variables
Deploy your chatbot to Vercel and configure the Anthropic API key as an environment variable. Never commit API keys to your repository. Vercel encrypts environment variables and injects them at build time and runtime.
# Push to GitHub
git init
git add .
git commit -m "AI chatbot with Claude streaming"
gh repo create ai-chatbot --public --push
# Deploy to Vercel
npm install -g vercel
vercel
# Set the API key
vercel env add ANTHROPIC_API_KEY
# Deploy to production
vercel --prodTip: Add rate limiting to your chat API route in production — without it, a single user could exhaust your API credits.
Tip: Set up Vercel's spend alerts to get notified if your Claude API costs spike unexpectedly.
Next Steps
- →Add markdown rendering with syntax highlighting for code blocks in assistant responses.
- →Implement multiple conversation threads with a sidebar to switch between them.
- →Add a system prompt editor so users can customize the assistant's behavior.
- →Integrate a vector database like Pinecone for retrieval-augmented generation (RAG) over your own documents.