Next.js 15 Fullstack (App Router + Server Actions) · Lektion

Streaming-Antworten und ReadableStream in Handlern

Geben Sie inkrementelle Daten mit ReadableStream für AI-Tokens, Logs und schrittweise Payloads zurück.

Lektion 3 von 413 Schritte

Streaming-Antworten und ReadableStream in Handlern ist eine kostenlose Next.js 15 Fullstack (App Router + Server Actions)-Lektion auf CoddyKit. Dies ist Lektion 3 von 4. Du kannst die komplette Lektion unten kostenlos lesen – dann übst du sie direkt im Browser mit einem integrierten Code-Editor und einem KI-Tutor rund um die Uhr. Sie ist Teil des Next.js 15 Fullstack (App Router + Server Actions)-Lernpfads, und dein Fortschritt wird über Web und CoddyKit-App synchronisiert. Der Next.js 15 Fullstack (App Router + Server Actions)-Kurs umfasst insgesamt 4 Lektionen.

Teile dieser Lektion wurden noch nicht übersetzt und werden auf Englisch angezeigt.

Why Stream a Response?

A normal Route Handler builds the entire body in memory, then sends it all at once. For AI token output, live logs, or large reports, that means the user stares at a blank screen until the very end.

Streaming flips this: you push chunks to the client as they become available. The browser starts rendering the first bytes immediately, time-to-first-byte drops, and you never hold the whole payload in RAM.

  • ReadableStream is the Web Standard primitive Next.js 15 uses for this.
  • You return it directly from a Route Handler inside a Response.
  • It works on both the Node.js and Edge runtimes.

The ReadableStream Shape

A ReadableStream is constructed with an object containing a start(controller) method. Inside it you call controller.enqueue(chunk) to push data and controller.close() when finished.

The chunk should be a Uint8Array of bytes. A TextEncoder turns a string into those bytes. This is pure Web API code, so it runs anywhere a modern JS engine exists.

const encoder = new TextEncoder();

const stream = new ReadableStream({
  start(controller) {
    controller.enqueue(encoder.encode("Hello, "));
    controller.enqueue(encoder.encode("streamed "));
    controller.enqueue(encoder.encode("world!"));
    controller.close();
  },
});

const response = new Response(stream);
console.log("Stream and Response created:", response instanceof Response);

Returning a Stream from a Handler

In app/api/.../route.ts you export an HTTP method function (here GET) and return a Response whose body is the stream.

Always set Content-Type. For plain incremental text, text/plain is fine; for structured event streams you would use text/event-stream (covered later).

This is framework code that needs the Next.js server, so it is not standalone-runnable.

// app/api/hello/route.ts
export async function GET(): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue(encoder.encode("chunk-1\n"));
      controller.enqueue(encoder.encode("chunk-2\n"));
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Streaming Over Time with async start

The real power shows when chunks arrive over time. The start method can be async, letting you await between enqueues.

Here a small delay simulates work (an AI provider, a slow query, a job step). Each line reaches the client the moment it is enqueued, not when the loop ends.

  • Use await to pace output without blocking the event loop.
  • Never forget controller.close() or the connection hangs open.
const encoder = new TextEncoder();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

const stream = new ReadableStream({
  async start(controller) {
    for (let i = 1; i <= 3; i++) {
      await sleep(50);
      controller.enqueue(encoder.encode(`step ${i}\n`));
    }
    controller.close();
  },
});

const reader = stream.getReader();
const decoder = new TextDecoder();
let out = "";
let result = await reader.read();
while (!result.done) {
  out += decoder.decode(result.value);
  result = await reader.read();
}
console.log(out.trim());

Streaming AI Tokens

The classic use case: piping an LLM's token stream straight to the browser so text appears word-by-word. Most AI SDKs expose an async iterable of partial chunks.

You loop over that iterable inside start and enqueue each token's text delta. The user sees the answer build in real time, just like a chat UI.

// app/api/chat/route.ts
import { openai } from "@/lib/openai";

export async function POST(req: Request): Promise<Response> {
  const { prompt } = await req.json();
  const encoder = new TextEncoder();

  const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    stream: true,
    messages: [{ role: "user", content: prompt }],
  });

  const stream = new ReadableStream({
    async start(controller) {
      for await (const part of completion) {
        const token = part.choices[0]?.delta?.content ?? "";
        if (token) controller.enqueue(encoder.encode(token));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Server-Sent Events (SSE) Format

For structured, named events the browser's EventSource understands, use the SSE wire format and the text/event-stream content type.

Each message is a line beginning with data: followed by a payload, terminated by a double newline (\n\n). You can serialize JSON after data:.

  • Content-Type: text/event-stream
  • Cache-Control: no-cache so proxies do not buffer.
  • Connection: keep-alive on the Node runtime.
// app/api/events/route.ts
export async function GET(): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (const status of ["queued", "running", "done"]) {
        const payload = JSON.stringify({ status });
        controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
        await new Promise((r) => setTimeout(r, 300));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

Streaming Progress Logs

Long-running jobs (imports, builds, batch processing) benefit from streaming a progress log. Each completed step is enqueued so the client can update a live console without polling.

The pattern is identical: do work, enqueue a line, repeat. Below is a runnable simulation of a multi-step job emitting NDJSON (one JSON object per line).

const encoder = new TextEncoder();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const steps = ["fetch", "transform", "upload"];

const stream = new ReadableStream({
  async start(controller) {
    for (let i = 0; i < steps.length; i++) {
      await sleep(30);
      const line = JSON.stringify({ step: steps[i], pct: ((i + 1) / steps.length) * 100 });
      controller.enqueue(encoder.encode(line + "\n"));
    }
    controller.close();
  },
});

const reader = stream.getReader();
const decoder = new TextDecoder();
let buf = "";
let r = await reader.read();
while (!r.done) {
  buf += decoder.decode(r.value);
  r = await reader.read();
}
for (const l of buf.trim().split("\n")) console.log(JSON.parse(l).step);

Handling Client Disconnects

If the user closes the tab mid-stream, you should stop doing work. The Request carries an AbortSignal on req.signal that fires when the connection drops.

Check req.signal.aborted inside your loop, and optionally use the stream's cancel() callback to release resources (close an LLM connection, abort a DB cursor).

// app/api/long/route.ts
export async function GET(req: Request): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 100; i++) {
        if (req.signal.aborted) break; // client left
        controller.enqueue(encoder.encode(`tick ${i}\n`));
        await new Promise((r) => setTimeout(r, 200));
      }
      controller.close();
    },
    cancel(reason) {
      console.log("stream cancelled:", reason);
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Error Handling Inside a Stream

Once you have returned the Response, the HTTP status is already 200 and headers are sent. You cannot switch to a 500 mid-stream.

So wrap risky work in try/catch and surface failures as a chunk (e.g. an SSE event: error line or a JSON error object), then close. Use controller.error(e) only when you want to abruptly tear down the stream.

// app/api/job/route.ts
export async function GET(): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        const data = await riskyWork();
        controller.enqueue(encoder.encode(JSON.stringify(data) + "\n"));
      } catch (err) {
        const msg = err instanceof Error ? err.message : "unknown";
        controller.enqueue(encoder.encode(JSON.stringify({ error: msg }) + "\n"));
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "application/x-ndjson" },
  });
}

Edge Runtime and Backpressure

Streaming shines on the Edge runtime. Opt in with export const runtime = "edge". The Edge runtime is built on Web Streams, so the same ReadableStream code works unchanged and starts flushing instantly from a location near the user.

Backpressure: if the client reads slowly, controller.enqueue still buffers. For high-volume producers, prefer a pull-based source or check controller.desiredSize to pace yourself and avoid unbounded memory growth.

// app/api/edge-stream/route.ts
export const runtime = "edge";

export async function GET(): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 5; i++) {
        // desiredSize < 0 means the consumer is behind
        if ((controller.desiredSize ?? 1) > 0) {
          controller.enqueue(encoder.encode(`edge ${i}\n`));
        }
        await new Promise((r) => setTimeout(r, 100));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Consuming the Stream on the Client

On the browser side, fetch gives you response.body, which is itself a ReadableStream. Read it with a reader and decode chunks as they arrive to update the UI progressively.

For SSE specifically you can instead use the native EventSource API. For raw text or NDJSON, the reader loop below is the universal approach.

// components/StreamReader.ts
export async function readStream(url: string, onChunk: (text: string) => void) {
  const res = await fetch(url);
  if (!res.body) throw new Error("No response body to stream");

  const reader = res.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    onChunk(decoder.decode(value, { stream: true }));
  }
}

Quick Check

Test your understanding of streaming Route Handlers.

Recap

You learned how to stream incremental data from Next.js 15 Route Handlers:

  • ReadableStream with start(controller) plus controller.enqueue() / controller.close() is the core primitive; return it inside a Response.
  • An async start lets you await between chunks for AI tokens, progress logs, and SSE events.
  • Use text/event-stream + data: ...\n\n for SSE, or NDJSON for line-delimited JSON.
  • Watch req.signal.aborted for disconnects and clean up in cancel().
  • Status and headers are fixed once you return, so report mid-stream errors as chunks.
  • The Edge runtime (runtime = "edge") runs the same Web Streams code; mind backpressure via desiredSize.
Kostenlos starten

Lerne TypeScript mit einem KI-Tutor — kostenlos

Schreibe und führe echten Code in deinem Browser aus, bekomme sofortige Hilfe von einem 24/7 KI-Tutor und setze dein Lernen im Web oder in der App fort.

Kurse
22
Lektionen
88

Häufig gestellte Fragen

Ist die Lektion „Streaming-Antworten und ReadableStream in Handlern“ kostenlos?

Ja — der vollständige Text von „Streaming-Antworten und ReadableStream in Handlern“ ist hier im Web kostenlos zu lesen. Um sie interaktiv zu üben (integrierter Code-Editor und 24/7 KI-Tutor) und den Rest des Next.js 15 Fullstack (App Router + Server Actions)-Kurses freizuschalten, upgrade auf CoddyKit PRO. Der Next.js 15 Fullstack (App Router + Server Actions)-Kurs umfasst insgesamt 4 Lektionen.

Was lerne ich in „Streaming-Antworten und ReadableStream in Handlern“?

Geben Sie inkrementelle Daten mit ReadableStream für AI-Tokens, Logs und schrittweise Payloads zurück. Du übst Next.js 15 Fullstack (App Router + Server Actions) mit praktischem Code, den du direkt im Browser ausführst, und ein 24/7 KI-Tutor beantwortet deine Fragen während du die Lektion bearbeitest.

Brauche ich Erfahrung, um Next.js 15 Fullstack (App Router + Server Actions) zu starten?

Keine Vorkenntnisse erforderlich. Next.js 15 Fullstack (App Router + Server Actions) auf CoddyKit ist für Anfänger bis fortgeschrittene Lernende strukturiert, sodass du hier starten oder von Anfang an beginnen und in deinem eigenen Tempo voranschreiten kannst. Dies ist Lektion 3 von 4.

Wie lange dauert die Lektion „Streaming-Antworten und ReadableStream in Handlern“?

Die meisten CoddyKit-Lektionen dauern etwa 5–10 Minuten. Jede ist kompakt und interaktiv, sodass du stetig Fortschritte machst und genau dort weitermachst, wo du aufgehört hast – im Web und in der App.

Kann ich in dieser Next.js 15 Fullstack (App Router + Server Actions)-Lektion Code schreiben und ausführen?

Ja. Jede Next.js 15 Fullstack (App Router + Server Actions)-Lektion enthält einen integrierten Code-Editor, sodass du echten Code direkt in deinem Browser schreibst und ausführst und sofort KI-Feedback erhältst — ohne lokale Einrichtung erforderlich.

Alle Lektionen in diesem Kurs

  1. RESTful-Route-Handler mit der Web Request API entwerfen
  2. Abwägungen zwischen Node Runtime und Edge Runtime
  3. Streaming-Antworten und ReadableStream in Handlern
  4. Request-Validierung und typisierte JSON-Antworten mit Zod
← Zurück zu Next.js 15 Fullstack (App Router + Server Actions)