◈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.

Each week: one thing Claude can do in your work that most people haven't figured out yet — plus the failure modes to avoid. No tutorials. No hype.

No spam. Unsubscribe anytime.

What to read next

Picked for where you are now

All articles →