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 as it's being generated — the same experience as Claude.ai or ChatGPT. This guide walks through how to wire that up in Next.js App Router.
It also covers two failure modes that hide inside almost every SSE tutorial. Both of them let the text appear correctly on screen while something else quietly goes wrong, which is exactly why they survive code review.
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 arrives as a series of events:
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
Concatenate each text_delta in order and you get the full response.
One thing to settle before writing any code: the Claude API never sends data: [DONE]. The terminator is event: message_stop. [DONE] is an OpenAI convention, and if you carry that habit over, your completion handler never fires. The text still streams in, so the UI looks fine — only the loading flag and the history append are left stranded. Half-working bugs are the slowest to find.
You will see [DONE] in the code below, but it is a sentinel our own Route Handler sends to our own frontend, not something forwarded from Claude. Keeping that distinction clear is what stops you from copying the bug out of someone else's gist.
| Event | Meaning | How we handle it |
|---|---|---|
message_start | Generation begins, includes input tokens | Read it if you track usage |
content_block_delta | A piece of content | Forward only delta.type === "text_delta" |
message_delta | Stop reason and output tokens | Useful for cost accounting |
message_stop | Normal completion | This is the terminator, not [DONE] |
error | Mid-stream failure | Arrives with HTTP 200 — handle explicitly |
Skipping the delta.type check works fine today and breaks the moment you enable extended thinking or tool use: thinking_delta and input_json_delta will leak straight into your visible text. Write the check now.
Route Handler with ReadableStream
In Next.js App Router, a route.ts file can return a ReadableStream directly:
// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Vercel Hobby cuts off at 10s by default — raise it for long generations
export const maxDuration = 60;
export async function POST(request: Request) {
const { messages } = await request.json();
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,
messages,
});
for await (const event of anthropicStream) {
// Text only — never thinking_delta or input_json_delta
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
const data = `data: ${JSON.stringify({ text: event.delta.text })}\n\n`;
controller.enqueue(encoder.encode(data));
}
// Mid-stream errors arrive with HTTP 200. Catch them yourself.
if (event.type === "error") {
const errData = `data: ${JSON.stringify({
error: event.error?.type ?? "stream_error",
})}\n\n`;
controller.enqueue(encoder.encode(errData));
controller.close();
return;
}
}
// Our own sentinel — not something Claude sent us
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch (error) {
const errData = `data: ${JSON.stringify({
error: error instanceof Error ? error.message : "stream_error",
})}\n\n`;
controller.enqueue(encoder.encode(errData));
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}Content-Type: text/event-stream is what tells the browser to treat this as a stream. The no-transform directive alongside no-cache matters more than it looks: some proxies will happily re-compress and re-chunk a response, which reintroduces buffering that no-cache alone does not prevent.
The event.type === "error" branch is the first silent failure closed off. A mid-stream error comes back inside a 200 response, so try/catch never sees it. Without that branch, a truncated answer gets stored as a successful one.
Losing Text at Chunk Boundaries — Measured
The second failure lives on the client. The common pattern is to call split("\n") on every chunk and look for data: lines.
That assumes SSE event boundaries line up with ReadableStream chunk boundaries. They don't. One chunk may hold several events, and one event may be split across two chunks. When it splits, JSON.parse throws, the fragment lands in a catch, and the text is gone.
Here is what that costs, measured in Node with a 23-character response split into four events and deliberately small chunks:
| Chunk size | Split-per-chunk (naive) | Buffered parser |
|---|---|---|
| 17 bytes | 0 of 23 chars (3 parse failures) | 23 of 23 |
| 24 bytes | 0 of 23 chars (3 parse failures) | 23 of 23 |
| 40 bytes | 6 of 23 chars | 23 of 23 |
| 4096 bytes | 23 of 23 | 23 of 23 |
That last row is the trap. With large chunks the naive parser looks perfect, and on localhost chunks are almost always large. Small chunks happen on slow connections, behind proxies, and on mobile networks. So the bug ships, and then it only affects some of your users, some of the time, with text that reads like it has holes punched in it.
The fix is small. Append to a buffer, split on the \n\n event delimiter, and carry the unfinished tail forward:
let buffer = "";
// on every read
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? ""; // last element may be incomplete — keep it
for (const part of parts) {
// part is one complete event
}The { stream: true } option on decode serves the same purpose one level down: it lets TextDecoder hold on to a multi-byte character that got cut in half. Omit it and non-ASCII text turns into replacement characters.
Custom Hook for Streaming State
Use fetch with ReadableStream rather than EventSource — EventSource only supports GET and can'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 [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (userText: string) => {
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
const newMessages: Message[] = [
...messages,
{ role: "user", content: userText },
];
setMessages(newMessages);
setStreamingText("");
setError(null);
setIsLoading(true);
let accumulated = "";
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newMessages }),
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error("Response body is empty");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let done = false;
while (!done) {
const { done: readerDone, value } = await reader.read();
if (readerDone) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";
for (const part of parts) {
const line = part.split("\n").find((l) => l.startsWith("data: "));
if (!line) continue;
const data = line.slice(6);
if (data === "[DONE]") {
done = true;
break;
}
// Swallow parse failures only — never swallow errors
let parsed: { text?: string; error?: string };
try {
parsed = JSON.parse(data);
} catch {
continue;
}
if (parsed.error) {
setError(parsed.error);
done = true;
break;
}
if (parsed.text) {
accumulated += parsed.text;
setStreamingText(accumulated);
}
}
}
await reader.cancel().catch(() => {});
} catch (e) {
if ((e as Error).name !== "AbortError") {
setError((e as Error).message);
}
} finally {
// Keep whatever arrived, even on failure
if (accumulated) {
setMessages((prev) => [
...prev,
{ role: "assistant", content: accumulated },
]);
}
setStreamingText("");
setIsLoading(false);
}
}, [messages]);
const cancel = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
setStreamingText("");
}, []);
return { messages, streamingText, isLoading, error, sendMessage, cancel };
}The important detail is where the error handling sits. A single catch {} wrapped around both JSON.parse and a throw new Error(parsed.error) will eat the error along with the parse failure. Running that exact shape locally: the old version finished with the partial text and error === null, the corrected version surfaced rate_limit_error. Only catch what you mean to catch.
Appending accumulated in finally is deliberate too. If a stream dies halfway, the text that did arrive is still worth reading. Keep it and let the person decide whether to retry.
Wiring Up the Chat UI
// 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, error, 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>
)}
{error && (
<div
role="alert"
className="bg-red-50 text-red-700 text-sm p-3 rounded-lg"
>
The response was interrupted ({error}). Please try again.
</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>
);
}The role="alert" on the error box is worth the two seconds it takes to type. A red border communicates nothing to a screen reader user, and an interrupted answer is exactly the kind of thing they need to hear about.
Things That Still Trip People Up
Timeouts stack. Raising Vercel's maxDuration doesn't help if a CDN or reverse proxy in front of it has its own idle timeout. For long silences, send a periodic SSE comment line (: ping\n\n) to keep the connection alive. Comment lines don't start with data: , so your parser ignores them without any changes.
Clean up on unmount. A stream that outlives its component leaves setState calls with nowhere to go. Call cancel() from a useEffect cleanup, or centralize the AbortController as shown above.
Cancelling doesn't necessarily stop the bill. abort() closes the connection, but generation may continue server-side. The billing behavior is covered in Cancelling Claude API Streams the Right Way.
Streams that die mid-response have several distinct causes. If you're debugging one, Claude API Streaming Stops Mid-Response works through them by symptom.
Next Steps
Once the basics work, persistence is usually the next thing you want. Right now a page reload wipes the conversation; pairing this with Cloudflare KV or Vercel KV keeps it across sessions.
For the protocol itself, the official streaming documentation is the primary source. The SDK's client.messages.stream() abstracts away most of the server-side work — but the chunk-boundary buffering and the error routing covered here live on the frontend, outside the SDK. That part stays yours to own.
Working solo as an indie developer, I missed both of these for a long time. The text showed up on screen, so I assumed it was working. It took actually measuring it to realize some readers on thin connections had been getting answers with holes in them.