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.