Follow Us

CodeWithSabir

  • Contact Us
  • Privacy Policy
  • About
  • Terms & Conditions

All Rights Reserved © 2026

  • Light
  • Dark
AI

Building an AI Chatbot with Next.js and Claude API

Sabir Soft
Sabir Lkhaloufi
  • April 5, 2026
  • 4 min read

Building an AI Chatbot with Next.js and Claude API

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.

Project Setup

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

// types/chat.ts
export interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
  timestamp: Date
}
 
export interface ChatSession {
  id: string
  messages: Message[]
  systemPrompt: string
}

API Route with Streaming

// 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',
    },
  })
}

Chat Hook

// 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 }
}

Chat UI Components

// 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>
  )
}

Key Takeaways

  • Use Edge Runtime for the chat API route — faster cold starts and global distribution
  • Stream responses with ReadableStream for real-time text appearance
  • Track conversation history in the hook and send it with every request so Claude maintains context
  • Implement an abort controller so users can stop a long response
  • Auto-grow the textarea with scrollHeight for a better typing experience
  • Keep the system prompt configurable — it's the easiest way to customize the chatbot's behavior
Popular Blogs
Claude AI vs ChatGPT: An Honest Comparison for Developers
  • April 28, 2026
AI Tools Every Developer Should Be Using in 2026
  • April 20, 2026
Using the Claude API in Real Projects: A Practical Developer Guide
  • April 15, 2026
Prompt Engineering for Developers: Write Prompts That Actually Work
  • April 10, 2026
Categories
AIDevOpsNext.jsMobile DevelopmentWeb Development

Related Posts

AI
Claude AI vs ChatGPT: An Honest Comparison for Developers
Sabir Khaloufi·Apr 28, 2026
AI
AI Tools Every Developer Should Be Using in 2026
Sabir Khaloufi·Apr 20, 2026
AI
Using the Claude API in Real Projects: A Practical Developer Guide
Sabir Khaloufi·Apr 15, 2026