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.
// 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.
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.
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.
// 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.
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/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.
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-Bufferingheader 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
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.
Frequently Asked Questions
Do I need the Edge runtime to stream from a Route Handler?
Why does my stream arrive all at once at the end?
When should I use WebSockets instead of server-sent events?
How long can a streaming response stay open?
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.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.