Streaming LLM Responses to a React UI with Next.js and SSE
Stream Claude responses token by token from a Next.js Route Handler to a React client over Server-Sent Events, with stop, retry, error handling, and accessible updates.
On this page· The flow
Why this matters
A model can take ten seconds to write a full answer, but it starts producing words in under a second. Streaming shows those words as they arrive, which turns a frozen spinner into an interface that feels instant. It also gives users control: they can stop a response that is heading the wrong way, and you stop paying for tokens nobody wants. Streaming is the default for any interface where a person waits for a model.
The flow#
Step 1 of 7
POST /api/chat { messages }
The client sends the conversation with fetch, so it can include a JSON body.
Show text descriptionHide text description
The browser, your Next.js Route Handler, and the Messages API, from the user pressing Send to the last token. Participants, left to right: Browser, Route Handler, Messages API.
- Browser → Route Handler: POST /api/chat { messages } · The client sends the conversation with fetch, so it can include a JSON body.
- Route Handler → Messages API: messages.stream({ … }) · The route opens a streaming request and passes request.signal, so a client disconnect cancels the model call.
- Messages API → Route Handler: message_start (streamed) · Metadata arrives first: message ID, model, initial usage.
- Messages API → Route Handler: content_block_delta: text_delta ×N (streamed) · Text arrives in small deltas, usually a few tokens each.
- Route Handler → Browser: event: text data: {"text":"…"} (streamed) · The route forwards each delta as an SSE event. The UI appends it to the last message.
- Messages API → Route Handler: message_delta: stop_reason, usage (streamed) · The end of the message carries the stop reason and final output token count.
- Route Handler → Browser: event: done data: { stop_reason, usage } · The UI leaves the streaming state; the server persists the final message.
Three pieces cooperate: a Route Handler that opens a model stream and re-emits text as Server-Sent Events, a small SSE parser in the browser, and a React component that appends text as it arrives and manages the message's state.
The server: a streaming Route Handler#
The SSE helper is the one from the APIs article:
export function sseEvent(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
export const SSE_HEADERS = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
} as const;The route validates input, opens a model stream tied to the request's abort signal, forwards text deltas, and finishes with a done event that carries the stop reason and usage.
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { SSE_HEADERS, sseEvent } from "../../../lib/sse";
const anthropic = new Anthropic();
const Body = z.object({
messages: z
.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().min(1).max(20_000) }))
.min(1)
.max(100),
});
export async function POST(request: Request) {
// In a real app: check the session and the user's rate limit here, before any model call.
const parsed = Body.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Invalid request body" }, { status: 400 });
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(sseEvent(event, data)));
try {
// Passing request.signal cancels the model call when the browser disconnects or the user stops.
const modelStream = anthropic.messages.stream(
{
model: "claude-opus-5-5",
max_tokens: 8192,
output_config: { effort: "low" },
system: "You are a helpful assistant for web developers. Use Markdown for code.",
messages: parsed.data.messages,
},
{ signal: request.signal },
);
for await (const event of modelStream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
send("text", { text: event.delta.text });
}
}
const final = await modelStream.finalMessage();
send("done", { stop_reason: final.stop_reason, usage: final.usage });
} catch (error) {
if (request.signal.aborted) return; // the client left: nobody is listening
console.error("chat stream failed", error);
send("error", {
message: error instanceof Anthropic.APIError ? "The assistant is unavailable right now." : "Something went wrong.",
});
} finally {
try {
controller.close();
} catch {
// Already closed because the client disconnected.
}
}
},
});
return new Response(stream, { headers: SSE_HEADERS });
}The client: parsing the stream#
The SSE parser is also from the APIs article. It turns the fetch response body into an async iterable of events:
export interface SSEMessage {
event: string;
data: string;
}
export async function* readSSE(response: Response): AsyncGenerator<SSEMessage> {
if (!response.ok || !response.body) throw new Error(`Stream failed with HTTP ${response.status}`);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) return;
buffer += value.replace(/\r\n/g, "\n");
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const raw = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf("\n\n");
let event = "message";
const data: string[] = [];
for (const line of raw.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
}
if (data.length > 0) yield { event, data: data.join("\n") };
}
}
}The UI: a message is a small state machine#
Each assistant message moves through a few states, and every one needs a visible UI, including the unhappy paths.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Model each assistant message as a small state machine. Every state needs a visible UI, including the unhappy ones.
- Idle: Waiting for the user's message.
- Submitting: The user's message appears immediately; the request is in flight; time to first token is often under a second.
- Streaming: Text arrives and is appended. A Stop button aborts the request.
- Done: The full message is saved; the input is enabled again.
- Stopped: The user stopped generation. Keep what arrived and mark it as stopped.
- Error: Network or provider failure. Show a clear message and a Retry button that resends the same conversation.
"use client";
import { useRef, useState } from "react";
import { readSSE } from "../lib/read-sse";
type Status = "streaming" | "done" | "stopped" | "error";
type ChatMessage = { id: string; role: "user" | "assistant"; content: string; status?: Status };
export function Chat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const controllerRef = useRef<AbortController | null>(null);
const last = messages.at(-1);
const streaming = last?.status === "streaming";
async function send(history: ChatMessage[]) {
const assistantId = crypto.randomUUID();
setMessages([...history, { id: assistantId, role: "assistant", content: "", status: "streaming" }]);
const controller = new AbortController();
controllerRef.current = controller;
const update = (patch: Partial<ChatMessage>, append = "") =>
setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, ...patch, content: m.content + append } : m)));
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history.map(({ role, content }) => ({ role, content })) }),
signal: controller.signal,
});
for await (const event of readSSE(response)) {
if (event.event === "text") update({}, (JSON.parse(event.data) as { text: string }).text);
else if (event.event === "done") update({ status: "done" });
else if (event.event === "error") update({ status: "error" });
}
} catch {
update({ status: controller.signal.aborted ? "stopped" : "error" });
}
}
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = input.trim();
if (!text || streaming) return;
setInput("");
const history = messages.filter((m) => m.status !== "error");
void send([...history, { id: crypto.randomUUID(), role: "user", content: text }]);
}
return (
<div>
<div role="log" aria-live="polite" aria-busy={streaming}>
{messages.map((m) => (
<div key={m.id} data-role={m.role}>
<strong>{m.role === "user" ? "You" : "Assistant"}: </strong>
<span style={{ whiteSpace: "pre-wrap" }}>{m.content}</span>
{m.status === "streaming" && m.content === "" ? <em> thinking…</em> : null}
{m.status === "stopped" ? <em> (stopped)</em> : null}
{m.status === "error" ? (
<span role="alert">
{" "}
Something went wrong.{" "}
<button type="button" onClick={() => void send(messages.slice(0, -1))}>
Retry
</button>
</span>
) : null}
</div>
))}
</div>
<form onSubmit={onSubmit}>
<label htmlFor="chat-input">Message</label>
<textarea id="chat-input" value={input} onChange={(e) => setInput(e.target.value)} rows={3} maxLength={20_000} />
{streaming ? (
<button type="button" onClick={() => controllerRef.current?.abort()}>
Stop
</button>
) : (
<button type="submit" disabled={!input.trim()}>
Send
</button>
)}
</form>
</div>
);
}import { Chat } from "../../components/chat";
export default function ChatPage() {
return (
<main>
<h1>Chat</h1>
<Chat />
</main>
);
}A few details that separate a demo from a product:
- Stop really stops. Aborting the
fetchcloses the connection; the route seesrequest.signalabort and cancels the model stream, so billing stops too. - The user's message appears immediately (optimistic update), before the server responds.
- Retry resends the same history without the failed assistant message.
role="log"witharia-live="polite"announces new content to screen readers;aria-busywhile streaming prevents a flood of announcements for each token.- Render Markdown safely in a real app: use a Markdown renderer that does not allow raw HTML, so model output cannot inject markup.
Common mistakes#
- Using
EventSource. It cannotPOSTa conversation. Usefetchand parse the stream. - Not wiring the abort signal. Without passing
request.signalto the model call, the server keeps generating after the user leaves. - Throwing inside the stream after headers are sent. Once streaming starts, the HTTP status is already 200; report failures as an
errorevent the client can show. - Buffering proxies or compression. If text arrives all at once, a proxy or compression layer is buffering. Keep
Cache-Control: no-transformand check your platform's streaming support. - Rendering raw HTML from the model. Treat model output like user input: render as text or sanitized Markdown.
Try it
Make it production-shaped
- Change the route to accept
{ conversationId, message }, load history from the database (see Databases), and save both turns afterfinalMessage(). - Add a
usagedisplay under each assistant message using thedoneevent. - Throttle the route with the Redis rate limiter and show a friendly message for HTTP 429 before the stream starts.
Check your understanding
Key takeaways
- Stream with
messages.stream()in a Route Handler and forward text deltas as Server-Sent Events. - Tie the model call to
request.signalso Stop and disconnects cancel generation. - Report errors as stream events; finish with a
doneevent carrying stop reason and usage. - Model each message as a state machine with visible streaming, done, stopped, and error states, and announce updates accessibly.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- APIs for AI Apps: REST, Server-Sent Events, WebSockets, and AuthenticationChoose between REST, Server-Sent Events, and WebSockets for AI features, stream events from a Next.js route handler, and secure every endpoint with sessions.
- Project: Build a Full-Stack AI Chat App with Next.js and ClaudeBuild a production-quality AI chat app: streaming replies, saved conversations in Postgres, history trimming, rate limits, usage tracking, and automatic titles.