Streaming Responses and Server-Sent Events from a Route Handler

Return a ReadableStream from a Route Handler, format it as server-sent events, and consume the updates in the browser with EventSource.

8 min read

Server-sent events are a one-way stream of text updates from the server to the browser, and a Next.js Route Handler can produce one by returning a Response built from a ReadableStream. The browser reads it with the built-in EventSource API, without any library.

This is the right tool for progress bars, job status, and live counters. The server pushes, the browser listens, and nothing is sent back over the same connection.

Here is streaming on its own, before any event formatting.

typescripttypescript
// app/api/chunks/route.ts
export function GET() {
  return new Response(new ReadableStream({
    async start(controller) {
      controller.enqueue(new TextEncoder().encode('first chunk\n'))
      await new Promise((resolve) => setTimeout(resolve, 500))
      controller.enqueue(new TextEncoder().encode('second chunk\n'))
      controller.close()
    },
  }))
}

Running curl --no-buffer against /api/chunks prints first chunk, pauses for half a second, then prints second chunk. That visible pause proves the producer is making data available over time, although an intermediary can still buffer it later in the deployment path.

Everything here is standard Web platform API, and it runs on the server in the Node.js runtime. Other response shapes from the same file are covered in returning JSON, files, streams, and redirects.

The server-sent events format

Server-sent events add a small text protocol on top of that stream. Each message is one or more prefixed lines, and a blank line marks the end of a message.

texttext
data: {"percent":25}
 
data: {"percent":50}
 

The useful prefixes are data: for the payload, event: for a named event type, id: for a resume marker, and retry: for the reconnect delay in milliseconds. A line starting with a colon is a comment, which is handy as a keep-alive ping through proxies that close idle connections.

The contents of the data fields become the event's data string in the browser. Messages without an event field reach onmessage, while named events need an addEventListener handler. JSON is a common payload choice, so the client parses it back out.

Producing the events

Keep the stream construction in lib/progress.ts so the handler stays readable. This helper turns any async source of progress numbers into encoded events.

typescripttypescript
const encoder = new TextEncoder()
export const progressStream = (events: AsyncIterable<number>) =>
  new ReadableStream({
    async start(controller) {
      for await (const percent of events) {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ percent })}\n\n`))
      }
      controller.enqueue(encoder.encode('event: done\ndata:\n\n'))
      controller.close()
    },
  })

Each value the source yields becomes one message, ending with the two newlines the format requires. After the source ends, the helper emits a named done event and closes the server stream.

Returning it from the route

The handler now only sets the headers that make the response an event stream and returns it. Note what it does not do: it never awaits the producing loop.

typescripttypescript
// app/api/progress/route.ts
import { progressStream } from '@/lib/progress'
import { watchJob } from '@/lib/jobs'
export function GET(request: Request) {
  return new Response(progressStream(watchJob(request.signal)), {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
    },
  })
}

The content type is what makes the browser treat the body as events instead of a plain download. The cache header requires revalidation, and no-transform asks intermediaries not to rewrite the body. Passing the request signal lets watchJob stop its source when the client disconnects, provided that helper handles AbortSignal.

None of these details guarantees immediate flushing, so buffering still needs deployment testing.

Ordinary Route Handlers are dynamic by default in Next.js 15 and 16. With Cache Components enabled, do not add use cache to a live event stream. In the older route segment configuration model, remove caching exports from this route.

Reading the events in the browser

EventSource is a browser API, so the code that uses it belongs in a Client Component. Put this hook in app/jobs/use-progress.ts, keep the connection in an effect, and keep the state it produces small.

typescripttypescript
import { useEffect, useState } from 'react'
export function useProgress(url: string) {
  const [percent, setPercent] = useState(0)
  useEffect(() => {
    const source = new EventSource(url)
    source.onmessage = (e) => setPercent(JSON.parse(e.data).percent)
    source.addEventListener('done', () => source.close())
    return () => source.close()
  }, [url])
  return percent
}

The server sends a named done event before closing, and its listener closes the client so a completed job does not reconnect and start over. The cleanup also closes it when the component unmounts. EventSource reconnects automatically after other connection losses, and browsers cap how many connections one origin may hold open.

The component that uses the hook carries the directive, which is what marks the boundary where client-side code starts.

App.tsxApp.tsx
// app/jobs/progress-bar.tsx
'use client'
import { useProgress } from './use-progress'
 
export function ProgressBar() {
  const percent = useProgress('/api/progress')
  return <progress aria-label="Job progress" value={percent} max={100} />
}

On screen the progress element fills as each event arrives, and the accessible label means a screen reader announces what is progressing rather than an unnamed bar. Because the value updates from server messages, no polling loop is involved.

A production component should also expose connection failures as visible text, not only a color change. Attach an onerror handler, return that state from the hook, and render a short status message beside the progress element.

The connection lifecycle

The sequence below shows what actually travels between the two sides, from the initial request to the close.

Server-sent events over one connection

One request opens the connection, and every later message travels on that same response body. The browser only stops listening when the client calls close, since a stream that ends on the server side triggers an automatic reconnect attempt.

What breaks in production

Streaming touches the network path, so the failures usually come from outside your code.

  • Reverse proxies buffer responses. NGINX in particular holds chunks until a large buffer fills, which is why an X-Accel-Buffering header set to no is a common addition.
  • Serverless hosts cap invocation time, so a long-lived connection can be cut mid-stream. Without Cache Components, platforms may read the pre-Cache-Components maxDuration route segment export.
  • Compression middleware can buffer the body while it compresses, which defeats the point of streaming.
  • The Edge runtime is deprecated, so keep these handlers on the default Node.js runtime as described in migrating off the Edge runtime.

Common mistakes

  • Awaiting the whole producing loop inside the handler before returning the response, which buffers everything and delivers it at once.
  • Forgetting the blank line after each message, so the browser never dispatches an event.
  • Leaving the content type unset, which makes EventSource fail immediately.
  • Not calling close in the client cleanup, which leaves reconnecting connections behind on navigation.
  • Ignoring request cancellation, which lets the server keep producing work after the client disconnects.

The short version

Build a ReadableStream, format each chunk as a data line followed by a blank line, and return the Response with an event stream content type. On the client, open EventSource in an effect and close it on cleanup. Page-level streaming with Suspense is a different mechanism, explained in streaming and progressive rendering.

Rune AI

Rune AI

Key Insights

  • A Route Handler can return a Response built from a ReadableStream.
  • Server-sent events are a text format: data lines separated by blank lines.
  • The content type must be text/event-stream for EventSource to work.
  • Return the response right away so chunks flush as they are produced.
  • EventSource reconnects on its own, so close it in the client cleanup.
RunePowered by Rune AI

Frequently Asked Questions

Do I need the Edge runtime to stream from a Route Handler?

No. Streaming works on the default Node.js runtime, which is also the runtime Cache Components requires. The Edge runtime is deprecated, so there is no reason to switch to it for streaming.

Why does my stream arrive all at once at the end?

The handler may be finishing all production before returning, or a browser, proxy, compression layer, or host may be buffering chunks. Return the stream immediately, then test the complete deployment path.

When should I use WebSockets instead of server-sent events?

Use server-sent events when the server pushes updates one way and the browser only listens. Choose WebSockets when the client also needs to send messages over the same open connection.

How long can a streaming response stay open?

That depends on the host, not on Next.js. Deployment platforms can impose invocation limits. The maxDuration export belongs to the route segment configuration model used without Cache Components, and only has an effect when the platform reads it.

Conclusion

A streaming Route Handler returns a Response wrapping a ReadableStream, and server-sent events are that stream formatted as text/event-stream so the browser can read it with EventSource. Return the response immediately, close the stream when the work is done, and close the connection in the client cleanup.