AI Codex
Developer PathStep 20 of 20
← Prev·Next →
Infrastructure & DeploymentHow It Works

Building a streaming chatbot with Next.js and Claude

In brief

End to end: API route, streaming SSE to the browser, React state, conversation history, and deployment. The complete working pattern.

10 min read·

Contents

Sign in to save

This is the complete pattern for a streaming Claude chatbot in Next.js. Not a simplified demo — the actual architecture you would use in production.

Stack: Next.js 14+ (App Router), TypeScript, Claude API. No UI library dependencies.

Project structure

src/
  app/
    api/
      chat/
        route.ts          # Claude API call + SSE streaming
    page.tsx              # Chat UI (client component)
  types.ts                # Message type definitions

Types

// src/types.ts
export type Message = {
  role: 'user' | 'assistant'
  content: string
}

The API route

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

  // Basic input validation
  if (!messages || !Array.isArray(messages)) {
    return new Response('Invalid request', { status: 400 })
  }

  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      try {
        const anthropicStream = await client.messages.stream({
          model: 'claude-sonnet-4-6',
          max_tokens: 1024,
          system: systemPrompt ?? 'You are a helpful assistant.',
          messages: messages.slice(-20), // Keep last 20 turns max
        })

        for await (const chunk of anthropicStream) {
          if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
            const data = JSON.stringify({ text: chunk.delta.text })
            controller.enqueue(encoder.encode(`data: ${data}

`))
          }
        }

        // Signal completion
        controller.enqueue(encoder.encode('data: [DONE]

'))
        controller.close()
      } catch (error) {
        const errMsg = error instanceof Error ? error.message : 'Unknown error'
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: errMsg })}

`))
        controller.close()
      }
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

The chat UI

// src/app/page.tsx
'use client'

import { useState, useRef, useEffect } from 'react'
import type { Message } from '@/types'

export default function ChatPage() {
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState('')
  const [isStreaming, setIsStreaming] = useState(false)
  const bottomRef = useRef<HTMLDivElement>(null)

  // Auto-scroll to bottom as new tokens arrive
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  async function sendMessage() {
    if (!input.trim() || isStreaming) return

    const userMessage: Message = { role: 'user', content: input }
    const newMessages = [...messages, userMessage]
    setMessages(newMessages)
    setInput('')
    setIsStreaming(true)

    // Add empty assistant message that we'll fill in as tokens arrive
    setMessages(prev => [...prev, { role: 'assistant', content: '' }])

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ messages: newMessages }),
      })

      if (!response.ok) throw new Error('API request failed')
      if (!response.body) throw new Error('No response body')

      const reader = response.body.getReader()
      const decoder = new TextDecoder()
      let buffer = ''

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('
')
        buffer = lines.pop() ?? ''

        for (const line of lines) {
          if (!line.startsWith('data: ')) continue
          const data = line.slice(6)
          if (data === '[DONE]') break

          try {
            const parsed = JSON.parse(data)
            if (parsed.error) throw new Error(parsed.error)
            if (parsed.text) {
              setMessages(prev => {
                const updated = [...prev]
                updated[updated.length - 1] = {
                  role: 'assistant',
                  content: updated[updated.length - 1].content + parsed.text,
                }
                return updated
              })
            }
          } catch {
            // Skip malformed chunks
          }
        }
      }
    } catch (error) {
      console.error('Streaming error:', error)
      setMessages(prev => {
        const updated = [...prev]
        updated[updated.length - 1] = {
          role: 'assistant',
          content: 'Something went wrong. Please try again.',
        }
        return updated
      })
    } finally {
      setIsStreaming(false)
    }
  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', maxWidth: '720px', margin: '0 auto', padding: '0 16px' }}>
      {/* Message list */}
      <div style={{ flex: 1, overflowY: 'auto', padding: '24px 0' }}>
        {messages.length === 0 && (
          <p style={{ color: '#888', textAlign: 'center', marginTop: '40px' }}>Start a conversation.</p>
        )}
        {messages.map((msg, i) => (
          <div key={i} style={{
            marginBottom: '16px',
            textAlign: msg.role === 'user' ? 'right' : 'left',
          }}>
            <div style={{
              display: 'inline-block',
              maxWidth: '80%',
              padding: '12px 16px',
              borderRadius: '12px',
              background: msg.role === 'user' ? '#D4845A' : '#f0f0f0',
              color: msg.role === 'user' ? '#fff' : '#1a1a1a',
              fontSize: '15px',
              lineHeight: 1.55,
              whiteSpace: 'pre-wrap',
            }}>
              {msg.content}
              {isStreaming && i === messages.length - 1 && msg.role === 'assistant' && (
                <span style={{ opacity: 0.4 }}>▋</span>
              )}
            </div>
          </div>
        ))}
        <div ref={bottomRef} />
      </div>

      {/* Input */}
      <div style={{ padding: '16px 0', borderTop: '1px solid #e5e5e5', display: 'flex', gap: '8px' }}>
        <textarea
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => {
            if (e.key === 'Enter' && !e.shiftKey) {
              e.preventDefault()
              sendMessage()
            }
          }}
          placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
          disabled={isStreaming}
          rows={1}
          style={{
            flex: 1,
            padding: '10px 14px',
            borderRadius: '8px',
            border: '1px solid #d5d5d5',
            fontSize: '15px',
            resize: 'none',
            outline: 'none',
            fontFamily: 'inherit',
          }}
        />
        <button
          onClick={sendMessage}
          disabled={isStreaming || !input.trim()}
          style={{
            padding: '10px 20px',
            borderRadius: '8px',
            background: '#D4845A',
            color: '#fff',
            border: 'none',
            fontSize: '15px',
            cursor: isStreaming ? 'not-allowed' : 'pointer',
            opacity: isStreaming || !input.trim() ? 0.6 : 1,
          }}
        >
          {isStreaming ? '...' : 'Send'}
        </button>
      </div>
    </div>
  )
}

Deploying to Vercel

# Install Vercel CLI
npm i -g vercel

# Deploy (it will ask you to log in on first run)
vercel

# Set your API key in production
vercel env add ANTHROPIC_API_KEY production

Or via the Vercel dashboard: Project Settings → Environment Variables → add ANTHROPIC_API_KEY.

After setting the env var, redeploy: vercel --prod.

What this gives you

This pattern handles:

  • Real-time streaming — tokens appear as they are generated
  • Conversation history — full multi-turn context up to 20 turns
  • Error recovery — streaming errors surface a friendly message, not a crash
  • Auto-scroll — follows the response as it generates
  • Keyboard shortcut — Enter to send, Shift+Enter for newlines
  • Loading state — button disabled and input shows ... during streaming

What it does not handle (next steps):

  • Persistence — conversations reset on page reload. Add a database for history.
  • Auth — all users share the same session. Add authentication before exposing to the public.
  • Rate limiting — all requests go to Claude with no user throttling. Add per-user rate limits before launch.
  • Markdown rendering — assistant responses render as plain text. Add a markdown renderer for code blocks and formatting.

The architecture scales. Add persistence by saving messages to a database after each response. Add auth by checking session tokens in the API route. Add prompt caching by marking your system prompt with cache_control.

Further reading

Related tools

Developer Path · Complete

You've reached the end of this path.

Go back to the path overview, or explore another learning path.

← Back to Developer PathAll learning paths →

Weekly brief

For people actually using Claude at work.

What practitioners are building, the mistakes worth avoiding, and the workflows that actually stick. No tutorials. No hype.

No spam. Unsubscribe anytime.

What to read next

Picked for where you are now

All articles →