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.
Contents
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
- Structured outputs documentation — forcing Claude to respond in specific JSON schemas
- Structured outputs on the Claude Developer Platform — announcement and examples