The first real friction point when integrating Claude into a Next.js app tends to be the same for everyone: a simple fetch call waits until Claude finishes generating the entire response before showing anything. Users stare at a blank area for several seconds. That's a bad experience, and it's completely avoidable.
With Server-Sent Events (SSE) streaming, you can show Claude's response word by word as it's being generated — the same experience as Claude.ai or ChatGPT. This guide walks through exactly how to wire that up in Next.js App Router, including the React hook pattern, cancellation, and the edge cases that trip people up.
How Claude's SSE Streaming Works
When you pass stream: true to the Claude API, the response comes back as text/event-stream instead of a complete JSON body. Text is sent in chunks as events:
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
Each text_delta event contains a small piece of the generated text. Concatenate them in order and you get the full response. For displaying streaming text, you only need to handle content_block_delta events with delta.type === "text_delta" — the other event types (message_start, message_stop, etc.) can be ignored unless you need usage statistics.
Route Handler with ReadableStream
In Next.js App Router, a route.ts file can return a ReadableStream directly, which makes SSE straightforward:
// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = new ReadableStream({
async start(controller) {
try {
const anthropicStream = await client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages,
});
for await (const event of anthropicStream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
const data = `data: ${JSON.stringify({ text: event.delta.text })}\n\n`;
controller.enqueue(new TextEncoder().encode(data));
}
}
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
controller.close();
} catch (error) {
const errData = `data: ${JSON.stringify({ error: "Stream error occurred" })}\n\n`;
controller.enqueue(new TextEncoder().encode(errData));
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}The three response headers matter: Content-Type: text/event-stream tells the browser this is an SSE stream, Cache-Control: no-cache prevents proxies and CDNs from buffering the response, and Connection: keep-alive keeps the TCP connection open for the duration of streaming.
Custom Hook for Streaming State
On the client side, use fetch with ReadableStream rather than EventSource — EventSource only supports GET requests and doesn't carry a request body.
// hooks/useClaudeStream.ts
import { useState, useCallback, useRef } from "react";
interface Message {
role: "user" | "assistant";
content: string;
}
export function useClaudeStream() {
const [messages, setMessages] = useState<Message[]>([]);
const [streamingText, setStreamingText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (userText: string) => {
// Cancel any in-flight stream before starting a new one
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
const newMessages: Message[] = [
...messages,
{ role: "user", content: userText },
];
setMessages(newMessages);
setStreamingText("");
setIsLoading(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newMessages }),
signal: controller.signal,
});
if (\!response.body) throw new Error("No response body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
// A single chunk may contain multiple SSE events
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
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) {
accumulated += parsed.text;
setStreamingText(accumulated);
}
} catch {
// Ignore parse errors from incomplete chunks
}
}
}
setMessages((prev) => [
...prev,
{ role: "assistant", content: accumulated },
]);
setStreamingText("");
} catch (error) {
if ((error as Error).name \!== "AbortError") {
console.error("Stream error:", error);
}
} finally {
setIsLoading(false);
}
}, [messages]);
const cancel = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
setStreamingText("");
}, []);
return { messages, streamingText, isLoading, sendMessage, cancel };
}Wiring Up the Chat UI
With the hook in place, the UI component stays clean:
// app/chat/page.tsx
"use client";
import { useState } from "react";
import { useClaudeStream } from "@/hooks/useClaudeStream";
export default function ChatPage() {
const [input, setInput] = useState("");
const { messages, streamingText, isLoading, sendMessage, cancel } =
useClaudeStream();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (\!input.trim() || isLoading) return;
const text = input;
setInput("");
await sendMessage(text);
};
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((msg, i) => (
<div
key={i}
className={`p-3 rounded-lg ${
msg.role === "user" ? "bg-blue-100 ml-8" : "bg-gray-100 mr-8"
}`}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
))}
{streamingText && (
<div className="bg-gray-100 mr-8 p-3 rounded-lg">
<p className="whitespace-pre-wrap">{streamingText}</p>
<span className="inline-block w-2 h-4 bg-gray-600 animate-pulse ml-1" />
</div>
)}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 border rounded-lg px-4 py-2"
disabled={isLoading}
/>
{isLoading ? (
<button
type="button"
onClick={cancel}
className="px-4 py-2 bg-red-500 text-white rounded-lg"
>
Stop
</button>
) : (
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-lg"
>
Send
</button>
)}
</form>
</div>
);
}Things That Trip People Up
Chunk boundaries don't align with SSE events. A single ReadableStream chunk can contain multiple SSE events, or a single event can be split across two chunks. The split("\n") approach handles the multi-event case, but strictly speaking you need a buffer to handle split events. For most use cases the current approach works fine; for production, consider using a proper SSE parser library.
Vercel timeout on long responses. The default function timeout on Vercel's Hobby plan is 10 seconds. Add export const maxDuration = 60; to your route handler to extend it to 60 seconds for longer generations.
Memory leaks on unmount. If a component unmounts while a stream is still running, the stream continues in the background. Use useEffect cleanup or ensure AbortController.abort() gets called on unmount.
Mixing SSE with Server Components. Next.js App Router's streaming applies to React Suspense boundaries, not Claude's SSE stream. Don't confuse the two — Claude's stream needs "use client" and a client-side fetch call.
Next Steps
Once basic streaming is working, the natural next step is persisting conversation history across page reloads. Cloudflare KV or Vercel KV work well for this. You can store conversation sessions server-side and retrieve them by session ID stored in a cookie.
For production code, the Anthropic TypeScript SDK provides a client.messages.stream() helper that abstracts away much of the low-level SSE handling shown here. The manual implementation above is useful for understanding what's happening under the hood, but the SDK helper is more robust for production use.