CodeWithSabir
HomeAIDevOpsNext.jsMobile DevelopmentWeb Development
CodeWithSabir
  • Home
  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development
  • About
  • Contact
CodeWithSabir

In-depth articles, tutorials, and guides on web development, React, Next.js, AI, and modern programming practices.

Topics

  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development

Company

  • About
  • Contact
  • Privacy Policy
  • Terms

© 2026 CodeWithSabir. All rights reserved.

Built with SabirSoft.com

Home/AI/Building an AI Chatbot with Next.js and Claude API
AI

Building an AI Chatbot with Next.js and Claude API

A practical guide to building a full-featured AI chatbot using Next.js 15 and the Claude API — streaming responses, conversation history, system prompts, and a polished UI.

Sabir KhaloufiSabir KhaloufiApril 5, 20264 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

bash
npx create-next-app@latest ai-chatbot --typescript --tailwind --app
npm install @anthropic-ai/sdk
env
# .env.local
ANTHROPIC_API_KEY=your_api_key_here

Types

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

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

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

tsx
// 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>
  )
}
tsx
// 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>
  )
}
tsx
// 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
#ai chatbot#next.js#claude api#streaming#react
Share:
Sabir Khaloufi — author photo

Written by

Sabir Khaloufi

Full-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.

On this page

  • Project Setup
  • Types
  • API Route with Streaming
  • Chat Hook
  • Chat UI Components
  • Key Takeaways

Related Articles

Claude AI vs ChatGPT: An Honest Comparison for Developers
AI

Claude AI vs ChatGPT: An Honest Comparison for Developers

A real, no-hype comparison of Claude AI and ChatGPT for developers — covering code generation, API usage, context windows, and which one actually helps you ship faster.

Sabir KhaloufiApril 28, 20265 min read
AI Tools Every Developer Should Be Using in 2026
AI

AI Tools Every Developer Should Be Using in 2026

A practical guide to the AI tools that are genuinely changing how developers write code, review PRs, write docs, and ship faster — with honest takes on each one.

Sabir KhaloufiApril 13, 20265 min read
Using the Claude API in Real Projects: A Practical Developer Guide
AI

Using the Claude API in Real Projects: A Practical Developer Guide

A hands-on guide to integrating the Claude API into real applications — covering streaming, tool use, prompt caching, system prompts, and production best practices.

Sabir KhaloufiApril 12, 20264 min read