Skip to content
AI Full-Stack Wiki

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.

Difficulty: Intermediate18 min readUpdated
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#

Streaming tokens from the model to the browserThe browser POSTs the messages; the route starts a model stream; each text delta from the model is re-emitted to the browser as an SSE event; at the end the route sends usage and a done event, or aborts if the user stops.BrowserRouteHandlerMessagesAPIPOST /api/chat{ messages }messages.stream({… })message_startcontent_block_delta:text_delta ×Nevent: textdata:{"text":"…"}message_delta:stop_reason,usageevent: donedata: {stop_reason,usage }

Step 1 of 7

POST /api/chat { messages }

The client sends the conversation with fetch, so it can include a JSON body.

Streaming an LLM response to a React UI over Server-Sent Events: the browser posts the conversation, the Next.js route opens a model stream, and text deltas are forwarded as SSE events until the message stops.
Show 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.

  1. Browser → Route Handler: POST /api/chat { messages } · The client sends the conversation with fetch, so it can include a JSON body.
  2. Route Handler → Messages API: messages.stream({ … }) · The route opens a streaming request and passes request.signal, so a client disconnect cancels the model call.
  3. Messages API → Route Handler: message_start (streamed) · Metadata arrives first: message ID, model, initial usage.
  4. Messages API → Route Handler: content_block_delta: text_delta ×N (streamed) · Text arrives in small deltas, usually a few tokens each.
  5. 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.
  6. Messages API → Route Handler: message_delta: stop_reason, usage (streamed) · The end of the message carries the stop reason and final output token count.
  7. 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:

lib/sse.ts
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.

app/api/chat/route.ts
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:

lib/read-sse.ts
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.

UI states of a streaming chat messageA message moves from idle to submitting to streaming and ends as done, stopped by the user, or failed with an error the user can retry.sendfirst tokenIdleinput enabledSubmittingoptimistic user messageStreamingappend deltas, show StopDonesave, enable inputStoppedkeep partial textErrormessage + Retry

Hover or tap a component to see what it does.

States of a streaming chat message in React: idle, submitting, streaming, and then done, stopped by the user, or error with retry.
Show 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.
components/chat.tsx
"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>
  );
}
app/chat/page.tsx
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 fetch closes the connection; the route sees request.signal abort 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" with aria-live="polite" announces new content to screen readers; aria-busy while 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 cannot POST a conversation. Use fetch and parse the stream.
  • Not wiring the abort signal. Without passing request.signal to 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 error event 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-transform and 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

  1. Change the route to accept { conversationId, message }, load history from the database (see Databases), and save both turns after finalMessage().
  2. Add a usage display under each assistant message using the done event.
  3. Throttle the route with the Redis rate limiter and show a friendly message for HTTP 429 before the stream starts.

Check your understanding

Why pass request.signal to messages.stream() in the Route Handler?
The model fails halfway through a stream. How should the route report it?
Why should production chat routes load history from the database instead of accepting it from the client?

Key takeaways

  • Stream with messages.stream() in a Route Handler and forward text deltas as Server-Sent Events.
  • Tie the model call to request.signal so Stop and disconnects cancel generation.
  • Report errors as stream events; finish with a done event 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.