SSE in Next.js App Router Client Components Permalink to this section
Part of React EventSource Hooks, under Frontend Consumption & Client Patterns.
The Next.js App Router splits a page into server components, which render once on the server, and client components, which hydrate and run in the browser. A Server-Sent Events stream belongs entirely on the client side of that split — but the page still benefits from server rendering, because the initial state can be real data rather than a spinner. This guide shows how to divide the work: server components fetch the snapshot, a client component opens one shared stream and applies deltas, and nothing mismatches during hydration.
Symptom & Developer Intent Permalink to this section
EventSource is not definederrors appear during build or server rendering.- The page flashes empty, then fills in once the stream connects.
- React logs hydration mismatch warnings on live-updating pages.
- Several client components each open their own stream to the same endpoint.
- Streams stay open after navigating to another route within the app.
The intent is a page that renders real data on the server, becomes live after hydration with a single stream, and cleans up on navigation.
Root Cause Analysis Permalink to this section
Server components cannot hold a connection: they run to completion on the server and send HTML. Client components run on the server too, during server-side rendering, before running in the browser — which is where EventSource errors come from if the constructor is called during render rather than in an effect. Hydration mismatches come from state that differs between the server render and the first client render, such as a stream value applied before hydration completes.
Step-by-Step Resolution Permalink to this section
Step 1 — Fetch the snapshot in a server component Permalink to this section
// app/orders/page.tsx — server component
import { LiveOrders } from './live-orders';
export const dynamic = 'force-dynamic'; // live data: do not prerender
export default async function OrdersPage() {
const res = await fetch(`${process.env.API_URL}/orders/snapshot`, { cache: 'no-store' });
const { orders, cursor } = await res.json();
return <LiveOrders initialOrders={orders} cursor={cursor} />;
}
Step 2 — Render the snapshot, then stream in an effect Permalink to this section
// app/orders/live-orders.tsx
'use client';
import { useEffect, useReducer } from 'react';
export function LiveOrders({ initialOrders, cursor }: { initialOrders: Order[]; cursor: string }) {
const [orders, dispatch] = useReducer(ordersReducer, initialOrders); // identical first render
useEffect(() => { // runs only in the browser, after hydration
const es = new EventSource(`/api/orders/stream?after=${encodeURIComponent(cursor)}`);
es.addEventListener('order.updated', (e) => dispatch({ type: 'updated', order: JSON.parse(e.data) }));
es.addEventListener('order.deleted', (e) => dispatch({ type: 'deleted', id: JSON.parse(e.data).id }));
return () => es.close(); // unmount or navigation closes the stream
}, [cursor]);
return <OrdersTable orders={orders} />;
}
Because state starts from the server-rendered snapshot and changes only in the effect, the first client render matches the server HTML exactly. Starting the stream after the snapshot’s cursor means no event between snapshot and stream is lost.
Step 3 — Share one stream across client components Permalink to this section
When a layout badge and a page table both need live data, put the connection in a context provider rendered by the root layout:
// app/stream-provider.tsx
'use client';
const StreamContext = createContext<Bus | null>(null);
export function StreamProvider({ children }: { children: React.ReactNode }) {
const bus = useMemo(() => createBus(), []);
useEffect(() => {
const es = new EventSource('/api/stream', { withCredentials: true });
for (const t of EVENT_TYPES) es.addEventListener(t, (e) => bus.emit(t, JSON.parse((e as MessageEvent).data)));
return () => es.close();
}, [bus]);
return <StreamContext.Provider value={bus}>{children}</StreamContext.Provider>;
}
export function useStreamEvent(type: string, handler: (d: unknown) => void) {
const bus = useContext(StreamContext)!;
const ref = useRef(handler); ref.current = handler;
useEffect(() => bus.on(type, (d) => ref.current(d)), [bus, type]);
}
// app/layout.tsx (server component) renders <StreamProvider> around {children}.
The provider lives in the root layout, which persists across navigations within the app, so the stream survives route changes and only closes when the user leaves the site. Pages that should not have a live connection can use a nested layout without the provider instead.
A few React-specific details matter in this provider. Strict Mode in development mounts, unmounts and remounts effects, so the provider briefly opens, closes and reopens the stream; that is expected and absent in production builds, but it means the effect must clean up correctly or development will show two open connections. The bus should be created once per provider instance (useMemo or a ref), not recreated on every render, or subscribers lose their listeners. And handlers should be stored in refs, as in useStreamEvent, so components can pass inline functions without resubscribing on every render — the pattern explained in preventing EventSource memory leaks in React.
When the snapshot and the stream both describe the same data, prefer a reducer that treats the snapshot as a special event rather than as separate state. Then a server action or router.refresh() that re-renders the server component with a newer snapshot can be dispatched into the same reducer, and version checks prevent an older snapshot from overwriting deltas that already arrived through the stream.
Step 4 — Keep the route handler dynamic Permalink to this section
The stream’s own route handler must not be cached or prerendered; see streaming SSE from Next.js route handlers.
Step 5 — Handle authentication and the edge Permalink to this section
Client components open the stream against the app’s own origin, so session cookies set by the Next.js app are sent automatically. If the stream is served by a different backend, either proxy it through a route handler on the same origin (keeping cookies and avoiding CORS) or use a fetch-based client with an Authorization header obtained from the session. Middleware that runs on every request should skip the stream path or at least avoid work that assumes a short request, such as rewriting responses.
Validation & Monitoring Permalink to this section
Load the page with JavaScript disabled: the snapshot must render. With JavaScript enabled, the console must be free of hydration warnings, and the Network panel should show exactly one stream request after hydration, no matter how many components consume it. Navigate between routes and confirm the stream persists (root provider) or closes (page-level component) as designed.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can a server component subscribe to an SSE stream?
No. Server components render once and return. Use them to fetch the initial snapshot and pass it to a client component that opens the stream.
Why do I get EventSource is not defined?
The constructor is being called during render, which also runs on the server for client components. Move it into useEffect, which only runs in the browser.
Should the stream provider be in the root layout?
Put it in the lowest layout that contains every route needing live data. The root layout makes the connection app-wide; a nested layout limits it to a section.
Can React Server Components streaming replace SSE?
No. RSC streaming progressively delivers one render; it is not a long-lived channel for later updates. SSE carries changes after the page is interactive.