Building a custom AI chatbot on Claude gives you full control: your own branding, custom system prompts, specific model selection, and usage you pay for directly. This guide builds a production-ready chatbot from scratch.
What We’re Building
- Streaming chat interface (text appears as Claude generates it)
- Conversation history (Claude remembers the current session)
- System prompt configuration
- Clean dark UI with Tailwind CSS
- Next.js App Router API route
Setup
npx create-next-app@latest claude-chat --typescript --tailwind --app
cd claude-chat
npm install @anthropic-ai/sdk
Create .env.local:
ANTHROPIC_API_KEY=sk-ant-your-key-here
Step 1: The API Route
Create src/app/api/chat/route.ts:
import Anthropic from '@anthropic-ai/sdk';
import { NextRequest } from 'next/server';
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
export async function POST(req: NextRequest) {
const { messages, systemPrompt } = await req.json();
// Validate input
if (!messages || !Array.isArray(messages)) {
return new Response('Invalid messages', { status: 400 });
}
// Create a streaming response
const stream = await client.messages.stream({
model: 'claude-3-7-sonnet-20250219',
max_tokens: 2048,
system: systemPrompt || 'You are a helpful assistant.',
messages: messages.map((msg: { role: string; content: string }) => ({
role: msg.role as 'user' | 'assistant',
content: msg.content,
})),
});
// Convert the Anthropic stream to a ReadableStream
const readable = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
try {
for await (const chunk of stream) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
controller.enqueue(encoder.encode(chunk.delta.text));
}
}
} catch (error) {
controller.error(error);
} finally {
controller.close();
}
},
});
return new Response(readable, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}
Step 2: The Chat Component
Create src/components/Chat.tsx:
'use client';
import { useState, useRef, useEffect } from 'react';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = async () => {
const userMessage = input.trim();
if (!userMessage || isLoading) return;
setInput('');
setIsLoading(true);
// Add user message
const updatedMessages: Message[] = [
...messages,
{ role: 'user', content: userMessage },
];
setMessages(updatedMessages);
// Add placeholder for assistant response
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: updatedMessages,
systemPrompt: 'You are a helpful, friendly assistant.',
}),
});
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
assistantContent += decoder.decode(value, { stream: true });
// Update the last message (assistant placeholder) with streaming content
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: assistantContent,
};
return updated;
});
}
} catch (error) {
console.error('Chat error:', error);
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: 'Sorry, something went wrong. Please try again.',
};
return updated;
});
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
return (
<div className="flex flex-col h-screen bg-gray-950 text-gray-100">
{/* Header */}
<div className="border-b border-gray-800 p-4">
<h1 className="text-lg font-semibold">AI Assistant</h1>
<p className="text-xs text-gray-400">Powered by Claude</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-500 mt-20">
<p className="text-lg mb-2">How can I help you today?</p>
<p className="text-sm">Ask me anything</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-100'
}`}
>
{msg.content}
{msg.role === 'assistant' && msg.content === '' && isLoading && (
<span className="inline-block w-2 h-4 bg-gray-400 animate-pulse ml-1" />
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-gray-800 p-4">
<div className="flex gap-3 items-end">
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message Claude..."
rows={1}
className="flex-1 bg-gray-800 text-gray-100 rounded-xl px-4 py-3 text-sm
resize-none outline-none focus:ring-2 focus:ring-blue-500
placeholder-gray-500 max-h-40 overflow-y-auto"
style={{
height: 'auto',
minHeight: '44px',
}}
/>
<button
onClick={sendMessage}
disabled={!input.trim() || isLoading}
className="bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700
disabled:cursor-not-allowed text-white rounded-xl p-3
transition-colors flex-shrink-0"
>
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
</div>
<p className="text-xs text-gray-600 mt-2 text-center">
Press Enter to send, Shift+Enter for new line
</p>
</div>
</div>
);
}
Step 3: The Page
Replace src/app/page.tsx:
import Chat from '@/components/Chat';
export default function Home() {
return <Chat />;
}
Step 4: Run It
npm run dev
Open http://localhost:3000. You have a working Claude chatbot with streaming responses.
Enhancements
Add Conversation Memory Limit
Long conversations use many tokens. Cap history at the last 20 messages:
// In the API route, before calling the API:
const recentMessages = messages.slice(-20);
Add Model Selection
Let users pick the model:
// In Chat.tsx, add state:
const [model, setModel] = useState('claude-3-7-sonnet-20250219');
// Add to the API body:
body: JSON.stringify({ messages: updatedMessages, model }),
// In the API route:
const { messages, systemPrompt, model } = await req.json();
// Use model in the client.messages.stream() call
Add Clear Conversation Button
<button
onClick={() => setMessages([])}
className="text-xs text-gray-500 hover:text-gray-300"
>
Clear conversation
</button>
Persist Conversations (localStorage)
// Load from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem('chat-messages');
if (saved) setMessages(JSON.parse(saved));
}, []);
// Save to localStorage when messages change
useEffect(() => {
localStorage.setItem('chat-messages', JSON.stringify(messages));
}, [messages]);
Deployment
Deploy to Vercel in one command:
npx vercel --prod
Add ANTHROPIC_API_KEY in your Vercel project environment variables (Settings → Environment Variables).
Cost Estimate
A typical user sending 20 messages/day with ~300 tokens per exchange:
- Input: ~20 × 2,000 context tokens × $3/1M = ~$0.12/day
- Output: ~20 × 300 output tokens × $15/1M = ~$0.09/day
- Total: ~$0.21/user/day, or ~$6.30/user/month
For a small app (100 active users): ~$630/month in API costs. Plan your pricing accordingly if building a commercial product.