Stream LLM text in a Next.js App Router app

· checked against the Next.js App Router docs · 5 min read

In the Next.js App Router, stream the model's text from a Route Handler by returning a Response built on a Web ReadableStream. Read it in a client component with fetch and a stream reader, append each chunk to one string in state, and pass that string to a streaming text component with streaming set until the reader finishes. Suspense and loading.js stream server-rendered UI; they are not for token streams.

How Next.js streams#

The Next.js Streaming guide covers two kinds. React and Suspense stream server-rendered UI: a page sends its shell, then fills in sections as their data resolves. Outside React rendering, Route Handlers stream raw responses with the Web Streams API, which is what a model's tokens need. The guide's own example returns new Response(stream) with a text/plain content type.

The component that shows the text holds state and runs effects, so it is a client component, marked with "use client". The page around it can stay a Server Component.

The same guide lists what breaks streaming after your code is right: reverse proxies such as Nginx that buffer by default (set X-Accel-Buffering: no), compression that waits for enough bytes, serverless platforms where streaming is not the default, and Safari, which buffers until 1,024 bytes have arrived.

Which Next.js piece does what#

Job Next.js mechanism Runs on
Call the model, stream its text Route Handler returning a ReadableStream Server
Read the stream, hold the text fetch, response.body.getReader(), useState Client component
Reveal words without reflow StreamingText with text and streaming Client component
Show the wait before the first token ThinkingState with startedAt Client component
Stream the rest of the page Suspense or loading.js Server Component

The code#

The Route Handler streams plain text. This one uses the AI SDK's textStream; any SDK that yields text chunks works the same way.

app/api/answer/route.ts
import { streamText } from "ai";

export async function POST(req: Request) {
  const { question }: { question: string } = await req.json();
  const result = streamText({ model: "openai/gpt-5.5", prompt: question });

  return new Response(result.textStream.pipeThrough(new TextEncoderStream()), {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "X-Content-Type-Options": "nosniff",
      "X-Accel-Buffering": "no",
    },
  });
}

The client component reads the body, keeps the whole text so far in state and hands it to the component on every chunk.

app/answer/answer.tsx
"use client";

import { useRef, useState } from "react";
import { StreamingText } from "@/components/pixilui/streaming-text";
import { ThinkingState } from "@/components/pixilui/thinking-state";

export function Answer({ question }: { question: string }) {
  const [text, setText] = useState("");
  const [streaming, setStreaming] = useState(false);
  const [startedAt, setStartedAt] = useState<number>();
  const abort = useRef<AbortController | null>(null);

  async function ask() {
    abort.current?.abort();
    abort.current = new AbortController();
    setText("");
    setStreaming(true);
    setStartedAt(Date.now());
    try {
      const response = await fetch("/api/answer", { method: "POST", body: JSON.stringify({ question }), signal: abort.current.signal });
      const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        setText((so) => so + value); // the whole text so far, not the chunk
      }
    } catch (error) {
      if ((error as Error).name !== "AbortError") setText((so) => so || "The answer did not arrive. Ask again.");
    } finally {
      setStreaming(false);
    }
  }

  return (
    <section aria-live="polite">
      <button type="button" onClick={streaming ? () => abort.current?.abort() : ask}>
        {streaming ? "Stop" : "Ask"}
      </button>
      {streaming && !text ? <ThinkingState label="Reading the question" startedAt={startedAt} /> : null}
      {text ? <StreamingText text={text} streaming={streaming} pauseOnHover /> : null}
    </section>
  );
}

Use it from a Server Component page: <Answer question="Why did checkout errors rise on Tuesday?" />. Install both with npx shadcn@latest add @pixilui/streaming-text @pixilui/thinking-state.

What the component handles that the reader loop does not#

Chunks arrive in uneven bursts and can split a word in two. Rendering text directly reflows the paragraph on every chunk. StreamingText:

  • Reveals whole words at about 50 ms a word, and holds back a half-arrived one.
  • Never moves a word once it is on screen, so earlier lines do not shift.
  • Rewinds to the shared prefix if the text changes in the middle, for a regenerate.
  • Drops the caret once streaming is false and the last word is shown.
  • Respects reduced motion: words appear as they arrive, with no rise.

When the framework already ships a UI#

Next.js ships no text-streaming component; it gives you the transport. If you use the AI SDK's useChat, it reads the stream for you and gives you message.parts; pass the text part to the same component, as in streaming text with the Vercel AI SDK. For answers that need full markdown (tables, lists, code blocks), use a markdown renderer that streams; StreamingText renders plain text with inline code.

When not to use this#

  • Short answers. A two-word label can render at once.
  • Server-rendered content that is slow, not streamed. Use Suspense and loading.js; a token stream is the wrong tool for a slow database query.
  • Structured output. Stream JSON into a form or table, not into a paragraph.

Questions#

How do I stream an LLM response in the Next.js App Router?

Return a Response built on a ReadableStream from a Route Handler, as the Next.js Streaming guide shows, and read it on the client with response.body.getReader(). Keep the text in state in a client component and append each chunk.

Why does my Next.js stream arrive all at once?

Something between server and browser is buffering. The Next.js Streaming guide names reverse proxies like Nginx (set X-Accel-Buffering: no), compression, serverless platforms where streaming must be enabled, and Safari, which waits for 1,024 bytes.

Can a Server Component stream tokens?

Server Components stream rendered UI through Suspense, not a growing string. The text needs state and effects, so the part that shows tokens is a client component, which a Server Component page can render.

Should I use loading.js for an AI answer?

No. loading.js shows a fallback while a route segment renders on the server. For the wait before the first token, show a thinking line in the client component that started the request.

Sources#