Streaming SSE from Next.js Route Handlers Permalink to this section
Part of Node.js Streaming Architecture Basics, under Backend Stream Generation & Connection Management.
Next.js App Router route handlers are Web-standard: they receive a Request and return a Response. That makes Server-Sent Events straightforward in principle — return a Response whose body is a ReadableStream of encoded frames. In practice several Next.js behaviours get in the way: static optimisation that caches the response at build time, a development server that buffers, abort signals that are easy to ignore, and hosting platforms that end functions after a fixed duration. This guide covers each.
Symptom & Developer Intent Permalink to this section
- The endpoint returns the same few events to every client, forever — it was rendered once at build time.
- In development, events arrive; in production behind the platform’s edge, they arrive all at once when the function ends.
- The server keeps producing events for clients that closed the tab.
- Streams die at a fixed time — 10, 15, 60 or 300 seconds — depending on the host.
EventSourcereceives nothing and the Network panel shows a completed response of typetext/plain.
The intent is a route handler that streams per request, stops work when the client leaves, and reconnects cleanly across platform time limits.
Root Cause Analysis Permalink to this section
Route handlers for GET can be statically optimised when they do not read dynamic request data. A handler that ignores the request and returns a stream may be evaluated at build time, freezing its output. Marking the route dynamic prevents it.
Duration limits come from the hosting model, not from Next.js. A self-hosted next start process is an ordinary long-lived Node.js server; a serverless deployment runs each request in a function with a maximum duration. When the limit is reached the platform ends the response, EventSource reconnects, and — if the stream is resumable — nothing is lost.
Work continuing after the client leaves happens because the stream’s producer does not observe request.signal, the AbortSignal that fires when the client disconnects.
Step-by-Step Resolution Permalink to this section
Step 1 — Return a streaming Response from a dynamic route Permalink to this section
// app/api/events/route.ts
export const dynamic = 'force-dynamic'; // never prerender or cache this route
export const runtime = 'nodejs'; // long-lived connections, full Node APIs
const encoder = new TextEncoder();
export async function GET(request: Request) {
const lastId = request.headers.get('last-event-id');
const stream = new ReadableStream({
async start(controller) {
const send = (s: string) => controller.enqueue(encoder.encode(s));
send('retry: 3000\n\n');
for (const e of await replayAfter(lastId)) send(frame(e));
const unsubscribe = bus.subscribe((e) => send(frame(e)));
const hb = setInterval(() => send(': hb\n\n'), 15_000);
request.signal.addEventListener('abort', () => { // client left
clearInterval(hb);
unsubscribe();
try { controller.close(); } catch {}
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
});
}
function frame(e: { id: string; type: string; data: unknown }) {
return `id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`;
}
Reading request.headers already makes the route dynamic, but force-dynamic states the intent explicitly and survives refactoring.
Step 2 — Hand over cleanly at the platform’s duration limit Permalink to this section
On serverless hosts, end the stream a little before the limit with a short retry hint, so the reconnect happens on your terms rather than as an abrupt cut:
export const maxDuration = 300; // seconds, where the platform allows configuring it
// inside start():
const deadline = setTimeout(() => {
send('retry: 250\n: handing over\n\n'); // reconnect quickly, with Last-Event-ID
cleanup();
controller.close();
}, (maxDuration - 5) * 1000);
With ids on every event and replay on connect, the handover is invisible to users. The pattern is covered generally in handling execution timeouts in serverless SSE, and host-specific limits in SSE on Vercel and Netlify functions.
Step 3 — Use a shared bus, not per-request producers Permalink to this section
In a serverless deployment each request may run in a different instance, so an in-memory EventEmitter only sees events published in the same instance. Subscribe to Redis, a managed pub/sub or a provider’s realtime channel inside the handler, and unsubscribe on abort. On a self-hosted Node.js server, a module-level bus works for a single process; use a broker when running several.
Step 4 — Consume it from a client component Permalink to this section
'use client';
import { useEffect, useState } from 'react';
export function LiveFeed() {
const [items, setItems] = useState<Item[]>([]);
useEffect(() => {
const es = new EventSource('/api/events');
es.addEventListener('item', (e) => setItems((xs) => [JSON.parse((e as MessageEvent).data), ...xs].slice(0, 100)));
return () => es.close();
}, []);
return <ul>{items.map((i) => <li key={i.id}>{i.text}</li>)}</ul>;
}
EventSource must live in a client component; server components render once and cannot hold a connection. SSE in Next.js App Router client components covers the client side, including sharing one stream across components.
Validation & Monitoring Permalink to this section
# Production build: events must arrive one at a time, not at the end.
next build && next start &
curl -sN http://localhost:3000/api/events | while IFS= read -r l; do echo "$(date +%T) $l"; done
# Abort handling: open and kill streams, then check the bus has no subscribers left.
for i in $(seq 1 50); do timeout 2 curl -sN http://localhost:3000/api/events > /dev/null & done; wait
curl -s http://localhost:3000/api/debug/subscribers # expect 0
Monitor the rate of reconnects per session. On serverless hosts it should match the handover interval; a higher rate means streams are ending earlier than planned, usually from an idle timeout that heartbeats should cover.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can I stream SSE from the Edge runtime?
Yes; the code is the same Web-standard Response. Edge runtimes have their own limits on duration and available APIs, and many cannot hold connections to brokers such as Redis over raw TCP, so check what your event source needs.
Why does my stream work in dev but arrive all at once in production?
Something between the function and the browser buffers the response, or the route was statically rendered. Force dynamic rendering, send the no-transform cache header, and test the deployed URL with curl -N.
Can server actions push events?
No. Server actions are request/response calls from the client. They can publish to the bus that the SSE route subscribes to, which is a good way to trigger events from mutations.
Should I use the Pages Router API routes instead?
They can stream too, via res.write on the Node.js response, but App Router route handlers use Web-standard streams that also run on edge runtimes, which makes them the more portable choice.