How do you return a streamed response from a route handler in the Next.js App Router, and why can a response built with NextResponse.json() never stream?
answer
- the platform's stream, not a Next API
- enqueue encoded chunks, then close
- the stream is the Response body
- json() needs the whole value first
- watch request.signal for disconnects
basics
~20 sBuild a ReadableStream, enqueue encoded chunks as they become available, and return it as the body of a Response. NextResponse.json() serializes one complete value into a buffered body, so nothing can leave the server until the whole value exists.
solid answer
~50 sRoute handlers are built on the Web `Request` and `Response` objects, so streaming here is the platform's mechanism rather than a Next API. You create a `ReadableStream` whose `start` callback enqueues `Uint8Array` chunks through the controller and calls `controller.close()` when finished, then return `new Response(stream, { headers })` — typically with `Content-Type: text/event-stream` for server-sent events or `text/plain` for a plain token stream. `NextResponse.json()` and `Response.json()` take a fully formed JavaScript value and serialize it in one pass, so by construction nothing can be sent until the value is complete; that is the whole difference. Two consequences worth stating: a streaming handler is inherently per-request and should never carry `dynamic = 'force-static'`, and once the first chunk leaves you can no longer change the status code, so mid-stream errors have to be expressed inside the body.
code
typescript · 25 lines// app/api/tokens/route.ts
import { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const encoder = new TextEncoder()
const words = ['streaming', 'from', 'a', 'route', 'handler']
const stream = new ReadableStream({
async start(controller) {
for (const word of words) {
if (request.signal.aborted) break
controller.enqueue(encoder.encode(`data: ${word}\n\n`))
await new Promise((resolve) => setTimeout(resolve, 200))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-store',
},
})
}go deeper
Know that a route handler returns a Response and that the response body can be a stream rather than a finished string, and that this is what powers token-by-token endpoints.
Show the mechanics: build a ReadableStream, encode each chunk with TextEncoder, enqueue and close through the controller, and pass the stream as the Response body.
Bring operational judgment — cancel upstream work when request.signal aborts, accept that the status code is fixed once the first chunk leaves, and design the client to survive a truncated stream.
Weigh streaming against the alternatives for the product: what held connections and long function durations cost you, and when a queued job plus polling is the more resilient shape than a long-lived response.
## Streaming is a platform feature here, not a Next API Because a route handler returns a Web `Response`, and a `Response` body may be a `ReadableStream`, streaming needs no framework support at all. This is one of the genuine payoffs of the App Router having been built on the platform objects: the code you write is the same code you would write in any fetch-based server, and it is testable by calling the exported handler with a hand-built `Request` and reading the returned stream. ## Building the stream The standard shape is a `ReadableStream` with a `start` callback that receives a controller: ```ts const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { for await (const piece of produceSomething()) { controller.enqueue(encoder.encode(piece)) } controller.close() }, }) ``` Three details matter. Chunks must be binary — a `Uint8Array`, which is what `TextEncoder.encode` produces. Enqueuing a plain string is the single most common mistake and fails rather than silently working. `controller.close()` is mandatory: without it the response never ends and the client hangs until a timeout somewhere kills it. And `start` may be async, which is what lets you await each upstream piece before enqueuing it. ## Returning it ```ts return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', }, }) ``` The headers are sent immediately, before a single chunk exists. That is the point — the client gets a response and starts reading while you are still producing. ## Why json() cannot do this `Response.json(value)` and `NextResponse.json(value)` accept a complete JavaScript value, serialize it to a string, and use that string as the body. The signature forbids streaming: the function cannot be called until `value` exists, and by then there is nothing left to stream. Calling it repeatedly does not help either — a handler returns exactly one `Response`, so the second call's result is simply discarded. Candidates who reach for "call json() in a loop" have not internalised that the response is a single object whose body happens to be a stream. ## Cancellation `NextRequest` extends the Web `Request`, so `request.signal` is an `AbortSignal` that fires when the client disconnects. A streaming endpoint that ignores it keeps producing into a socket nobody is reading: a closed browser tab leaves an upstream call or a database cursor running, and on a metered platform you pay for the whole thing. Check `request.signal.aborted` between chunks, or attach an `abort` listener that tears down the upstream work. ```ts if (request.signal.aborted) { controller.close() return } ``` The stream's own `cancel(reason)` callback is the other hook — it runs when the consumer stops reading, and it is the right place to release resources. ## Status codes and mid-stream errors The status line and headers are committed the moment the response begins. If your producer throws after three chunks, you cannot retroactively turn a 200 into a 500. Two options remain: emit an error chunk in whatever framing the client understands and then `controller.close()`, or call `controller.error(err)`, which tears the stream down and surfaces to the client as a truncated body. Neither is as good as a clean status, so design the client to recognise both a well-formed terminator and an abrupt end. This is the tax of streaming, and knowing it is what separates someone who has shipped a streaming endpoint from someone who has read about one. ## Where it fits Streaming handlers are always per-request — there is nothing to prerender — so they belong firmly in the dynamic bucket and must never carry a static segment config. They also hold a connection open for the duration, which is a real resource on any host: long-running streams change your concurrency and function-duration picture in a way that a fast JSON endpoint does not. When output arrives in a burst rather than progressively, a plain buffered response is simpler and cheaper; streaming earns its complexity only when the user genuinely benefits from seeing the first bytes early.
- How does a streaming route handler know the client has gone away?`NextRequest` extends the Web `Request`, so `request.signal` is an `AbortSignal` that fires when the connection closes. Check `request.signal.aborted` between chunks or attach an `abort` listener, and use the stream's `cancel` callback to release resources. Without it, a closed tab leaves the upstream call running and billed.
- What can a streaming handler no longer do once the first chunk has been sent?Change the status code or the headers — both were committed when the response began. A failure after that point has to be expressed inside the body: emit an error chunk in your framing and close, or call `controller.error()`, which the client sees as a truncated body. Clients for streaming endpoints need to handle both endings.
- Does returning a stream affect whether the handler can be cached?In practice yes. A streamed response is produced per request, so the handler belongs in the dynamic bucket and should never carry `dynamic = 'force-static'` — there is no single finished value to store at build time. Treat streaming endpoints as always-executed.
saying these in an interview costs you the question
- Tries to stream by calling NextResponse.json() in a loop
- Enqueues plain strings instead of encoded Uint8Array chunks
- Forgets controller.close(), leaving the request hanging
- Expects to set a status code after streaming has begun
- Ignores client disconnects and leaks upstream work