Building a chatbot that actually feels good to use requires more than wiring up an API call. You need streaming so responses appear in real-time, conversation history so context is maintained, and a UI that feels responsive. This guide builds a complete chatbot from scratch.
npx create-next-app@latest ai-chatbot --typescript --tailwind --app
npm install @anthropic-ai/sdk# .env.local
ANTHROPIC_API_KEY=your_api_key_here// types/chat.ts
export interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
}
export interface ChatSession {
id: string
messages: Message[]
systemPrompt: string
}// app/api/chat/route.ts
import Anthropic from '@anthropic-ai/sdk'
import { NextRequest } from 'next/server'
const claude = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
export const runtime = 'edge'
export async function POST(req: NextRequest) {
const { messages, systemPrompt } = await req.json()
// Validate
if (!messages?.length) {
return Response.json({ error: 'Messages required' }, { status: 400 })
}
const stream = await claude.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 2048,
system: systemPrompt || 'You are a helpful assistant.',
messages: messages.map((m: any) => ({
role: m.role,
content: m.content,
})),
stream: true,
})
const encoder = new TextEncoder()
const readable = new ReadableStream({
async start(controller) {
try {
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
controller.enqueue(encoder.encode(event.delta.text))
}
}
} finally {
controller.close()
}
},
})
return new Response(readable, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
},
})
}// hooks/useChat.ts
'use client'
import { useState, useCallback, useRef } from 'react'
import type { Message } from '@/types/chat'
import { nanoid } from 'nanoid'
export function useChat(systemPrompt = 'You are a helpful assistant.') {
const [messages, setMessages] = useState<Message[]>([])
const [isStreaming, setIsStreaming] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const sendMessage = useCallback(async (content: string) => {
if (!content.trim() || isStreaming) return
const userMessage: Message = {
id: nanoid(),
role: 'user',
content: content.trim(),
timestamp: new Date(),
}
const assistantMessage: Message = {
id: nanoid(),
role: 'assistant',
content: '',
timestamp: new Date(),
}
setMessages(prev => [...prev, userMessage, assistantMessage])
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: [...messages, userMessage].map(m => ({
role: m.role,
content: m.content,
})),
systemPrompt,
}),
signal: abortRef.current.signal,
})
if (!res.ok) throw new Error('Request failed')
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
setMessages(prev =>
prev.map(m =>
m.id === assistantMessage.id
? { ...m, content: m.content + text }
: m
)
)
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
setError('Failed to get response. Please try again.')
setMessages(prev => prev.filter(m => m.id !== assistantMessage.id))
} finally {
setIsStreaming(false)
}
}, [messages, isStreaming, systemPrompt])
const stopStreaming = useCallback(() => {
abortRef.current?.abort()
}, [])
const clearMessages = useCallback(() => {
setMessages([])
setError(null)
}, [])
return { messages, isStreaming, error, sendMessage, stopStreaming, clearMessages }
}// components/MessageBubble.tsx
import { Message } from '@/types/chat'
export function MessageBubble({ message }: { message: Message }) {
const isUser = message.role === 'user'
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
{!isUser && (
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-bold mr-2 flex-shrink-0 mt-1">
AI
</div>
)}
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap ${
isUser
? 'bg-blue-600 text-white rounded-br-none'
: 'bg-gray-100 text-gray-800 rounded-bl-none'
}`}
>
{message.content || (
<span className="opacity-50 animate-pulse">Thinking...</span>
)}
</div>
</div>
)
}// components/ChatInput.tsx
'use client'
import { useState, useRef, KeyboardEvent } from 'react'
interface Props {
onSend: (message: string) => void
onStop: () => void
isStreaming: boolean
disabled?: boolean
}
export function ChatInput({ onSend, onStop, isStreaming, disabled }: Props) {
const [input, setInput] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
function handleSend() {
if (!input.trim()) return
onSend(input)
setInput('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
isStreaming ? onStop() : handleSend()
}
}
return (
<div className="border-t bg-white p-4">
<div className="flex items-end gap-3 max-w-3xl mx-auto">
<textarea
ref={textareaRef}
value={input}
onChange={e => {
setInput(e.target.value)
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`
}}
onKeyDown={handleKeyDown}
placeholder="Message Claude... (Enter to send, Shift+Enter for new line)"
className="flex-1 resize-none border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-48"
rows={1}
disabled={disabled}
/>
<button
onClick={isStreaming ? onStop : handleSend}
disabled={disabled || (!isStreaming && !input.trim())}
className={`px-4 py-3 rounded-xl text-white text-sm font-medium transition-colors ${
isStreaming
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-600 hover:bg-blue-700 disabled:opacity-40'
}`}
>
{isStreaming ? 'Stop' : 'Send'}
</button>
</div>
</div>
)
}// app/page.tsx — Main chat page
'use client'
import { useEffect, useRef } from 'react'
import { useChat } from '@/hooks/useChat'
import { MessageBubble } from '@/components/MessageBubble'
import { ChatInput } from '@/components/ChatInput'
export default function ChatPage() {
const { messages, isStreaming, error, sendMessage, stopStreaming, clearMessages } = useChat(
'You are a helpful coding assistant. Be concise and include code examples when relevant.'
)
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
return (
<div className="flex flex-col h-screen bg-white">
{/* Header */}
<header className="border-b px-6 py-4 flex items-center justify-between">
<h1 className="font-semibold text-gray-900">Claude Assistant</h1>
{messages.length > 0 && (
<button onClick={clearMessages} className="text-sm text-gray-500 hover:text-gray-700">
Clear chat
</button>
)}
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-3xl mx-auto">
{messages.length === 0 && (
<div className="text-center text-gray-400 mt-20">
<p className="text-lg font-medium">How can I help you today?</p>
<p className="text-sm mt-2">Ask me anything about code, tech, or development.</p>
</div>
)}
{messages.map(message => (
<MessageBubble key={message.id} message={message} />
))}
{error && (
<div className="text-red-500 text-sm text-center my-2">{error}</div>
)}
<div ref={bottomRef} />
</div>
</div>
{/* Input */}
<ChatInput
onSend={sendMessage}
onStop={stopStreaming}
isStreaming={isStreaming}
/>
</div>
)
}ReadableStream for real-time text appearancescrollHeight for a better typing experience