DH
8 min read

Real-Time Updates with Server-Sent Events (SSE) in Next.js 15

Stream multiple named events from a Next.js 15 endpoint and consume them via the useEventSource hook from react-use-websocket.

nextjssse

Server-Sent Events (SSE) let you push a continuous stream of updates from your server to the browser over a plain HTTP connection—no WebSocket handshake, no bidirectional overhead. In this tutorial you'll build a multi-event SSE endpoint in Next.js 15 (App Router) that emits two named event types—news and stats—and consume them in a React client component using the useEventSource hook from react-use-websocket.

By the end you'll understand how SSE named events work, where the common pitfalls are (memory leaks, reconnection, unbounded state), and how to extend the pattern for production use.


1. Project Setup

Create a new Next.js 15 project with the App Router, a src directory, and TypeScript:

npx create-next-app@latest multi-event-sse-app \
--app \
--src-dir \
--typescript

This creates a folder structure like:

multi-event-sse-app/
├─ src/
│ ├─ app/
│ │ ├─ layout.tsx
│ │ ├─ page.tsx
│ │ └─ api/ (we'll put our SSE route here)
├─ next.config.ts
├─ package.json
├─ tsconfig.json
└─ ...

Then install react-use-websocket:

cd multi-event-sse-app
npm install react-use-websocket

Version note: react-use-websocket v4.x requires React 18. If you're on React 17, pin to [email protected] instead.


2. Creating an SSE Endpoint

We'll create a streaming route.ts that periodically sends two different named events:

  1. news — Simulated "breaking news" headlines.
  2. stats — Random numeric data such as "active users" or "sales figures."

Create the file at src/app/api/stream/route.ts:

// src/app/api/stream/route.ts
import { NextResponse } from "next/server";

const HEADLINES = [
"New Study Reveals Benefits of Walking Daily",
"Tech Startups Rally in Surging Market",
"Local Basketball Team Clinches Playoffs",
"Farmers Embrace High-Tech Irrigation Methods",
];

export async function GET() {
const encoder = new TextEncoder();

const readableStream = new ReadableStream({
start(controller) {
const intervalId = setInterval(() => {
// 1) Send a "news" event with a random headline
const randomHeadline =
HEADLINES[Math.floor(Math.random() * HEADLINES.length)];
const newsChunk = `event: news\ndata: ${JSON.stringify({
headline: randomHeadline,
})}\n\n`;
controller.enqueue(encoder.encode(newsChunk));

// 2) Send a "stats" event with random numbers
const activeUsers = Math.floor(Math.random() * 1000);
const sales = (Math.random() * 10000).toFixed(2);
const statsChunk = `event: stats\ndata: ${JSON.stringify({
activeUsers,
sales,
})}\n\n`;
controller.enqueue(encoder.encode(statsChunk));
}, 3000);

// Return a cleanup function — called when the client disconnects
return () => {
clearInterval(intervalId);
};
},
});

return new NextResponse(readableStream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}

Key points

ConceptDetail
Named eventsevent: news / event: stats before each data: line tells the browser which listener to fire. Without an event: line the browser fires the generic message event.
Double newlineEach SSE message must end with \n\n. A single \n separates fields within one message.
JSON payloadsEach event type carries its own data shape—a headline string for news, and activeUsers/sales numbers for stats.
CleanupReturning a function from start() ensures the interval is cleared when the client disconnects, preventing a memory/CPU leak.
Cache-Control: no-cacheRequired to prevent intermediate proxies (nginx, CDN) from buffering the stream.

Visit http://localhost:3000/api/stream while the dev server is running and you'll see the raw event stream in your browser.


3. Consuming SSE with useEventSource

In Next.js App Router, layouts and pages are Server Components by default. You need to opt into a Client Component (via the "use client" directive) to use browser APIs such as EventSource. The useEventSource hook from react-use-websocket wraps the native EventSource API and adds a React-friendly interface with named-event routing.

Create or edit src/app/page.tsx:

"use client";

import { useState } from "react";
import { useEventSource } from "react-use-websocket";

interface StatsEntry {
activeUsers: number;
sales: string;
}

export default function Home() {
// Cap history to avoid unbounded growth in long-lived sessions
const MAX_ITEMS = 50;

const [headlines, setHeadlines] = useState<string[]>([]);
const [statsLog, setStatsLog] = useState<StatsEntry[]>([]);

const { readyState } = useEventSource("/api/stream", {
// Pass `withCredentials: true` if your endpoint requires cookies/auth
// withCredentials: true,
events: {
news: (evt) => {
try {
const payload = JSON.parse(evt.data);
setHeadlines((prev) =>
[payload.headline, ...prev].slice(0, MAX_ITEMS)
);
} catch (error) {
console.error("Failed to parse news payload:", error);
}
},
stats: (evt) => {
try {
const payload: StatsEntry = JSON.parse(evt.data);
setStatsLog((prev) => [payload, ...prev].slice(0, MAX_ITEMS));
} catch (error) {
console.error("Failed to parse stats payload:", error);
}
},
},
});

// 0 = CONNECTING, 1 = OPEN, 2 = CLOSED
const connectionStatus = ["Connecting", "Open", "Closed"][readyState] ?? "Unknown";

return (
<main style={{ padding: "1rem" }}>
<h1>SSE Demo: News &amp; Stats</h1>
<p>
Connection: <strong>{connectionStatus}</strong>
</p>

<section style={{ marginTop: "1rem" }}>
<h2>Latest Headlines</h2>
<ul>
{headlines.map((headline, idx) => (
<li key={idx}>{headline}</li>
))}
</ul>
</section>

<section style={{ marginTop: "1rem" }}>
<h2>Stats Log</h2>
<ul>
{statsLog.map((entry, idx) => (
<li key={idx}>
Active Users: {entry.activeUsers}, Sales: ${entry.sales}
</li>
))}
</ul>
</section>
</main>
);
}

How useEventSource works

  • useEventSource(endpoint, options) opens a native EventSource connection to endpoint.
  • options.events is a map of named event types → callback functions. When the server sends event: news, the news callback fires with the raw MessageEvent; event: stats fires stats, and so on.
  • readyState mirrors the native EventSource ready states: 0 (CONNECTING), 1 (OPEN), 2 (CLOSED).
  • The hook automatically closes the connection when the component unmounts, so you don't need a manual useEffect cleanup.

Bounded state — why it matters

The two .slice(0, MAX_ITEMS) calls keep the arrays capped at 50 entries each. Without a cap, arrays grow indefinitely for as long as the connection is open: in a dashboard that runs for hours, this silently consumes memory and degrades React reconciliation performance.


4. Reconnection and Error Handling

The native EventSource API reconnects automatically after a dropped connection (the browser waits ~3 seconds by default). However there are scenarios you should handle explicitly:

Server-side: signal the retry interval

Add a retry: field to your event stream to tell the browser how long to wait before reconnecting (milliseconds):

// Inside your ReadableStream start():
controller.enqueue(encoder.encode("retry: 5000\n\n")); // 5-second retry

Server-side: send a heartbeat to keep connections alive

Proxies and load balancers often close idle connections after 30–60 seconds. A periodic comment line keeps the connection alive without triggering event handlers:

// A comment line (starts with `:`) — ignored by EventSource listeners
controller.enqueue(encoder.encode(": heartbeat\n\n"));

Client-side: detect and display a closed connection

// Add to your component
useEffect(() => {
if (readyState === 2) {
console.warn("SSE connection closed. Waiting for automatic reconnect…");
}
}, [readyState]);

Authentication: pass credentials via query params or cookies

The native EventSource only supports GET requests and cannot set custom headers. The two common patterns are:

  1. Cookie-based auth — set withCredentials: true in the options; the browser sends cookies automatically.
  2. Query-string token — append a short-lived token: useEventSource("/api/stream?token=xyz", …).

5. Trying It Out

  1. Start the dev server:
    npm run dev
  2. Visit http://localhost:3000.
  3. Every 3 seconds you'll see:
    • One "news" event prepended to "Latest Headlines."
    • One "stats" event prepended to "Stats Log."

Open the Network tab in DevTools → select the /api/stream request → click the EventStream sub-tab to watch raw SSE frames arrive in real time.


6. Expanding the Pattern

IdeaHow
Different frequenciesUse separate setInterval calls per event type, or a single interval with a counter to skip every N ticks.
More event typesAdd alerts, notifications, or chat keys to both the server event: lines and the client events map.
Real data sourcesReplace the random generators with database queries, message-queue consumers (Redis Pub/Sub, Kafka), or third-party API calls.
Last-Event-ID resumptionSet id: <value> on each server message; the browser sends Last-Event-ID on reconnect so you can replay missed events.
Vercel / edge deploymentSwitch the route to the Edge Runtime (export const runtime = "edge") for lower cold-start latency; note that the Edge Runtime has a 30-second response limit unless you use streaming correctly.

7. SSE vs. WebSockets — Quick Reference

FeatureSSEWebSocket
DirectionServer → Client onlyFull-duplex
ProtocolPlain HTTP/1.1 or HTTP/2Upgraded connection (ws://)
Auto-reconnectBuilt into the browserMust implement manually
Named event typesNative (event: field)Custom (message envelope)
Proxy/firewall friendlinessHigh (standard HTTP)Lower (non-standard upgrade)
Max concurrent connections6 per origin (HTTP/1.1)Unlimited

Choose SSE when you only need server-to-client streaming and want simplicity; choose WebSockets when clients also need to send frequent messages back to the server.


8. Conclusion

By combining SSE with Next.js 15's App Router and the useEventSource hook from react-use-websocket, you can:

  1. Push real-time data to connected clients with minimal overhead—no handshake complexity.
  2. Route named events (news, stats, etc.) directly to typed callbacks, keeping component logic clean.
  3. Handle edge cases safely—bounded state, heartbeats, retry intervals, and auth patterns all covered above.

Key takeaways:

  • Endpoint: a streaming route.ts that writes event: <name>\ndata: …\n\n chunks.
  • Client: a "use client" component with useEventSource, which routes named events to callbacks.
  • Production hygiene: cap array state, send heartbeats, set retry:, and handle readyState === 2.
  • Versatility: perfect for live dashboards, notification feeds, leaderboards, or any scenario needing continuous one-way server-to-client updates.
Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps