| name | nextjs-chatbot-streaming |
| description | Next.js 14 streaming patterns for real-time chatbot responses with OpenAI SDK. Use when implementing streaming chat, API routes for LLM integration, real-time UI updates, or managing loading states in chat interfaces. |
| license | MIT |
| metadata | {"author":"camaral-team","version":"1.0.0","framework":"nextjs-14","runtime":"nodejs"} |
Next.js Chatbot Streaming
Streaming implementation patterns for real-time chatbot responses using Next.js 14 App Router and OpenAI SDK.
When to Apply
Use this skill when:
- Implementing streaming chat responses
- Setting up API routes for LLM integration
- Handling real-time UI updates in chat interfaces
- Managing loading states and progressive rendering
- Optimizing perceived response time
Key Patterns
1. Server-Side Streaming (CRITICAL)
Pattern: Use ReadableStream with OpenAI streaming for progressive responses
import { OpenAI } from 'openai'
import { NextRequest, NextResponse } from 'next/server'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
export async function POST(req: NextRequest) {
const { message, history } = await req.json()
const messages = [
{ role: 'system', content: SYSTEM_PROMPT },
...history,
{ role: 'user', content: message }
]
const stream = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages,
stream: true,
temperature: 0.3
})
const encoder = new TextEncoder()
const readable = new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || ''
if (content) {
controller.enqueue(encoder.encode(content))
}
}
} catch (error) {
controller.error(error)
} finally {
controller.close()
}
}
})
return new Response(readable, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked'
}
})
}
2. Client-Side Consumption (CRITICAL)
Pattern: Use ReadableStreamDefaultReader for progressive UI updates
'use client'
import { useState } from 'react'
export function Chat() {
const [messages, setMessages] = useState<Message[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const sendMessage = async (content: string) => {
setIsLoading(true)
setError(null)
const userMessage: Message = { role: 'user', content }
setMessages(prev => [...prev, userMessage])
const assistantMessageIndex = messages.length + 1
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
try {
const response = (, {
: ,
: { : },
: .({
: content,
: messages
})
})
(!response.) {
()
}
reader = response.!.()
decoder = ()
accumulatedText =
() {
{ done, value } = reader.()
(done)
chunk = decoder.(value, { : })
accumulatedText += chunk
( {
newMessages = [...prev]
newMessages[assistantMessageIndex] = {
: ,
: accumulatedText
}
newMessages
})
}
} (err) {
errorMessage = err ? err. :
()
( prev.(, -))
} {
()
}
}
(
)
}
3. Error Handling (HIGH)
Pattern: Graceful fallback with user-friendly error messages
export class LLMError extends Error {
constructor(
message: string,
public code: string,
public retryable: boolean = false
) {
super(message)
this.name = 'LLMError'
}
}
export function handleLLMError(error: unknown): LLMError {
if (error instanceof OpenAI.APIError) {
switch (error.status) {
case 429:
return new LLMError(
'Too many requests. Please wait a moment.',
'RATE_LIMIT',
true
)
case 401:
return new LLMError(
'Authentication failed. Please check API key.',
'AUTH_ERROR',
false
)
case :
(
,
,
)
:
(
,
,
)
}
}
(
,
,
)
}
{
stream = openai...({...})
} (error) {
llmError = (error)
.(
{ : llmError., : llmError. },
{ : llmError. ? : }
)
}
4. Optimistic UI Updates (MEDIUM)
Pattern: Show user message immediately, stream assistant response
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!input.trim() || isLoading) return
const userMessage = input.trim()
setInput('')
const optimisticMessage: Message = {
role: 'user',
content: userMessage,
timestamp: new Date()
}
setMessages(prev => [...prev, optimisticMessage])
setTimeout(() => scrollToBottom(), 0)
await sendMessage(userMessage)
}
5. Auto-scroll Behavior (MEDIUM)
Pattern: Scroll to bottom only when user is near bottom
import { useRef, useEffect } from 'react'
export function Chat() {
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const [shouldAutoScroll, setShouldAutoScroll] = useState(true)
const handleScroll = () => {
const container = scrollContainerRef.current
if (!container) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShouldAutoScroll(distanceFromBottom < 100)
}
useEffect(() => {
if (shouldAutoScroll) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [messages, shouldAutoScroll])
return (
<div
ref={scrollContainerRef}
onScroll={handleScroll}
=
>
{messages.map((msg, i) => (
))}
)
}
Anti-Patterns
❌ Don't: Wait for full response before showing anything
const response = await fetch('/api/chat')
const fullText = await response.text()
setMessages(prev => [...prev, { role: 'assistant', content: fullText }])
✅ Do: Stream chunks progressively
const reader = response.body!.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
updateMessage(decoder.decode(value))
}
❌ Don't: Use polling for updates
setInterval(() => {
fetch('/api/chat/status').then(...)
}, 1000)
✅ Do: Use native streaming
const stream = await openai.chat.completions.create({ stream: true })
for await (const chunk of stream) { ... }
Performance Tips
- Debounce input - Prevent accidental double-sends
- Limit history - Send only last 10-20 messages to API
- Use AbortController - Cancel requests on component unmount
- Optimize re-renders - Use React.memo for message bubbles
- Batch updates - Update UI in requestAnimationFrame for smooth streaming
Testing
describe('POST /api/chat', () => {
it('should stream response chunks', async () => {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ message: 'Hello', history: [] })
})
expect(response.ok).toBe(true)
expect(response.headers.get('content-type')).toContain('text/plain')
const reader = response.body!.getReader()
const { done, value } = await reader.read()
expect(done).toBe(false)
expect(value).toBeInstanceOf(Uint8Array)
})
})
References