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 defined errors 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.

What runs where on a live App Router page Layers of a live page from the server component fetching the snapshot, through the client component rendering the snapshot on server and client, to the effect opening the stream in the browser only. What runs where on a live App Router page Server component fetch snapshot, pass as props Client component render same snapshot, server + browser useEffect browser only: open EventSource Stream deltas update state after hydration
Only the effect layer runs exclusively in the browser. Everything above it must produce identical output on server and client.

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.

From server render to live updates Sequence diagram of the server component fetching a snapshot and cursor, the browser hydrating with the same data, the effect opening the stream from the cursor, and deltas updating state. From server render to live updates Server component Browser API GET /orders/snapshot orders + cursor 5120 HTML with snapshot hydrate (same state) stream after 5120 order.updated 5121
The stream starts exactly where the snapshot ended, so the page goes live with no gap and no mismatch.

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.

Where each concern belongs on the page Matrix of four concerns — initial data, the connection, applying deltas, and cross-route sharing — mapped to the server component, the client component and the root layout provider. Where each concern belongs on the page Concern Server component Client component Root layout provider Initial snapshot fetch here no no EventSource never in useEffect shared here Apply deltas no reducer emit to consumers Survives navigation no unmounts yes
Each concern has one home. Putting the connection anywhere that renders on the server is the root of most App Router SSE bugs.

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.