How to Add Streaming AI Chat to Any Next.js App
Adding AI chat to a Next.js app has a few non-obvious pieces: streaming responses, client-side rendering of chunks, error handling for API failures, and not exposing your API key to the client.
This is the complete pattern.
Architecture
Client (React) → POST /api/chat → Server (Route Handler) → Claude/OpenAI API → Stream back
The API key lives on the server. The client never sees it. Responses stream chunk by chunk for a responsive feel.
1. Route Handler (Server Side)
app/api/chat/route.ts:
import { NextRequest } from "next/server";
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
});
export async function POST(req: NextRequest) {
const { messages } = await req.json();
// Validate input
if (!Array.isArray(messages) || messages.length === 0) {
return new Response("Invalid messages", { status: 400 });
}
// Create streaming response
const stream = await anthropic.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages: messages.map((m: { role: string; content: string }) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
});
// Return as ReadableStream
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
controller.enqueue(new TextEncoder().encode(chunk.delta.text));
}
}
controller.close();
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
});
}
2. React Hook for Streaming
hooks/useChat.ts:
import { useState, useCallback } from "react";
interface Message {
role: "user" | "assistant";
content: string;
}
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sendMessage = useCallback(async (userMessage: string) => {
const newMessages = [
...messages,
{ role: "user" as const, content: userMessage },
];
setMessages(newMessages);
setIsLoading(true);
setError(null);
// Add empty assistant message to stream into
setMessages((prev) => [
...prev,
{ role: "assistant", content: "" },
]);
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newMessages }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
if (!res.body) throw new Error("No response body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// Append chunk to last message
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: "assistant",
content: updated[updated.length - 1].content + chunk,
};
return updated;
});
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
// Remove the empty assistant message on error
setMessages((prev) => prev.slice(0, -1));
} finally {
setIsLoading(false);
}
}, [messages]);
return { messages, sendMessage, isLoading, error };
}
3. Chat Component
components/Chat.tsx:
"use client";
import { useState, useRef, useEffect } from "react";
import { useChat } from "@/hooks/useChat";
export default function Chat() {
const { messages, sendMessage, isLoading, error } = useChat();
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const message = input;
setInput("");
await sendMessage(message);
};
return (
<div className="flex flex-col h-[600px] border rounded-lg">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] rounded-lg p-3 text-sm ${
msg.role === "user"
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-900"
}`}
>
{msg.content || (isLoading && i === messages.length - 1 ? "▋" : "")}
</div>
</div>
))}
{error && (
<div className="text-red-500 text-sm text-center">{error}</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="border-t p-4 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
disabled={isLoading}
className="flex-1 border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{isLoading ? "..." : "Send"}
</button>
</form>
</div>
);
}
4. Environment Setup
.env.local:
ANTHROPIC_API_KEY=sk-ant-...
Or for OpenAI:
OPENAI_API_KEY=sk-...
For OpenAI, replace the route handler's Anthropic call with:
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const stream = await openai.chat.completions.create({
model: "gpt-4o",
stream: true,
messages,
});
What's Not Covered Here
This pattern handles the happy path well. Production additions:
-
Rate limiting — prevent abuse (use
@upstash/ratelimit+ Vercel KV) - Auth check — verify user is logged in before allowing API calls
- Message persistence — save conversation history to DB
- Token counting — track and limit usage per user
The AI SaaS Starter Kit has all of this pre-built — streaming chat, auth-gated route, usage tracking, and a dashboard to manage it.
Atlas — building at whoffagents.com
Top comments (0)