# Stream an LLM Reply to the Browser With Server-Sent Events in Next.js

A working pattern for streaming a model's reply word by word: a Route Handler that returns an event stream, and a small client that reads it, with cancellation and error handling.

Published 2026-11-01. 2 min read. Category: Tutorials. By Mutahar Aslam, Founder & CEO of Paraline Tech.

## Key takeaways

- Server-sent events (SSE) are plain text over a long HTTP response: lines starting with event: and data:, separated by a blank line.
- A Next.js Route Handler can return a ReadableStream as the response body.
- Use fetch with a stream reader on the client, because EventSource only supports GET.
- Handle cancellation and errors on both sides, or a closed tab keeps a model call running.

When a language model answers, waiting for the whole reply feels slow. Streaming it as it is generated makes an app feel immediate. The standard way to do that over plain HTTP is **server-sent events** (SSE). We use the same idea in an AI tutor for school students, where answers appear word by word. Here is a version you can run in a Next.js app.

## What an event stream looks like

An SSE response has the content type `text/event-stream` and stays open. The server writes messages as text:

```text
event: token
data: {"text":"Hello"}

event: token
data: {"text":" world"}

event: done
data: {}
```

Each message is a few `event:` and `data:` lines followed by a blank line. That is all the protocol is.

## Step 1: the Route Handler

Create `app/api/chat/route.ts`. It returns a `ReadableStream` as the response body and writes to it as tokens arrive.

```ts
// app/api/chat/route.ts
export const runtime = "nodejs";

// Stand-in for your model call: yields a piece of text at a time.
async function* generateTokens(prompt: string, signal: AbortSignal) {
  for (const word of `You asked: ${prompt}`.split(" ")) {
    if (signal.aborted) return;
    await new Promise((resolve) => setTimeout(resolve, 80));
    yield `${word} `;
  }
}

export async function POST(request: Request) {
  const { prompt } = (await request.json()) as { prompt: string };
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      const send = (event: string, data: unknown) =>
        controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
      try {
        for await (const text of generateTokens(prompt, request.signal)) send("token", { text });
        send("done", {});
      } catch {
        send("error", { message: "The reply failed. Try again." });
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
    },
  });
}
```

Three details matter. `request.signal` aborts when the browser disconnects, so you can stop the model call instead of paying for output nobody will read. Errors are sent as an `error` event, because the HTTP status has already been sent by then. And `no-transform` tells proxies not to buffer or compress the stream.

## Step 2: read it in the browser

`EventSource` is the built-in way to read SSE, but it can only make GET requests. A chat prompt belongs in a POST body, so read the stream with `fetch`:

```ts
// lib/stream-chat.ts
export async function streamChat(
  prompt: string,
  onToken: (text: string) => void,
  signal?: AbortSignal,
) {
  const response = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
    signal,
  });
  if (!response.ok || !response.body) throw new Error("Request failed");

  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;

    // Messages end with a blank line; the last piece may be incomplete.
    const messages = buffer.split("\n\n");
    buffer = messages.pop() ?? "";

    for (const message of messages) {
      const event = /^event: (.*)$/m.exec(message)?.[1];
      const data = /^data: (.*)$/m.exec(message)?.[1];
      if (!event || !data) continue;
      if (event === "token") onToken((JSON.parse(data) as { text: string }).text);
      if (event === "error") throw new Error((JSON.parse(data) as { message: string }).message);
    }
  }
}
```

The `buffer` is the part people forget. A network chunk can end in the middle of a message, so keep the incomplete tail and add the next chunk to it.

## Step 3: use it in a component

```tsx
"use client";
import { useRef, useState } from "react";
import { streamChat } from "@/lib/stream-chat";

export function Chat() {
  const [reply, setReply] = useState("");
  const controller = useRef<AbortController | null>(null);

  async function ask(prompt: string) {
    controller.current?.abort();
    controller.current = new AbortController();
    setReply("");
    try {
      await streamChat(prompt, (text) => setReply((r) => r + text), controller.current.signal);
    } catch (error) {
      if ((error as Error).name !== "AbortError") setReply("Something went wrong.");
    }
  }

  return (
    <div>
      <button onClick={() => ask("Explain photosynthesis simply")}>Ask</button>
      <button onClick={() => controller.current?.abort()}>Stop</button>
      <p aria-live="polite">{reply}</p>
    </div>
  );
}
```

Starting a new question aborts the old one, and the Stop button does the same. Aborting on the client closes the connection, which triggers `request.signal` on the server.

## Things to check before you ship

- **Limits on your host.** Streaming keeps a request open, so look at the maximum duration for functions on your platform.
- **Proxies and buffering.** Test through your real production setup, not just locally, since some layers buffer responses.
- **Accessibility.** `aria-live="polite"` lets screen readers follow the reply without interrupting.
- **Cost control.** Cancel upstream calls when the user leaves, and cap output length.

Need an AI feature built into your product, with streaming, grounding and cost control? [Talk to Paraline Tech](https://paralinetech.com/contact).

## Common questions

### Why not WebSockets?

For one-way streaming from server to browser, SSE is simpler: it is plain HTTP, works through most proxies, and needs no extra server. WebSockets are better when the browser also sends frequent messages.

### Why not use EventSource?

EventSource can only make GET requests and cannot send a request body or custom headers. For a chat prompt you want POST, so read the stream with fetch.

### Will this work on my host?

It needs a platform that lets a response stay open while you write to it. Check your host's limits on function duration and streaming.

Canonical page: https://blog.paralinetech.com/stream-llm-reply-with-server-sent-events-nextjs
