SSE is simpler than WebSockets for one-way data streaming. Here is how to use it correctly.
SSE Route Handler
// app/api/events/route.ts
import { NextRequest } from "next/server";
// Connection registry
const connections = new Map<string, ReadableStreamDefaultController>();
export async function GET(request: NextRequest) {
const userId = request.nextUrl.searchParams.get("userId");
const lastEventId = request.headers.get("Last-Event-ID");
if (!userId) {
return new Response("userId required", { status: 400 });
}
const stream = new ReadableStream({
start(controller) {
// Register connection
connections.set(userId, controller);
// Send initial connection event
const event = formatSSE({
event: "connected",
data: { userId, timestamp: Date.now() },
id: crypto.randomUUID(),
});
controller.enqueue(new TextEncoder().encode(event));
// If client reconnected, send missed events
if (lastEventId) {
sendMissedEvents(controller, userId, lastEventId);
}
// Keep-alive heartbeat every 30 seconds
const heartbeat = setInterval(() => {
try {
controller.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
} catch {
clearInterval(heartbeat);
}
}, 30000);
// Clean up on disconnect
request.signal.addEventListener("abort", () => {
clearInterval(heartbeat);
connections.delete(userId);
try {
controller.close();
} catch {
// Already closed
}
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // Disable Nginx buffering
},
});
}
interface SSEMessage {
event?: string;
data: unknown;
id?: string;
retry?: number;
}
function formatSSE(message: SSEMessage): string {
let result = "";
if (message.id) result += `id: ${message.id}\n`;
if (message.event) result += `event: ${message.event}\n`;
if (message.retry) result += `retry: ${message.retry}\n`;
result += `data: ${JSON.stringify(message.data)}\n\n`;
return result;
}
// Send an event to a specific user
export function sendToUser(userId: string, event: SSEMessage) {
const controller = connections.get(userId);
if (!controller) return false;
try {
const formatted = formatSSE(event);
controller.enqueue(new TextEncoder().encode(formatted));
return true;
} catch {
connections.delete(userId);
return false;
}
}
// Broadcast to all connections
export function broadcast(event: SSEMessage) {
const formatted = formatSSE(event);
const encoded = new TextEncoder().encode(formatted);
for (const [userId, controller] of connections) {
try {
controller.enqueue(encoded);
} catch {
connections.delete(userId);
}
}
}
async function sendMissedEvents(
controller: ReadableStreamDefaultController,
userId: string,
lastEventId: string,
) {
// In production, query your database for events after lastEventId
// This is a placeholder for the recovery mechanism
}
Client-Side Hook
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
interface UseSSEOptions {
url: string;
onMessage?: (data: unknown) => void;
onError?: (error: Event) => void;
events?: Record<string, (data: unknown) => void>;
enabled?: boolean;
}
export function useSSE({
url,
onMessage,
onError,
events = {},
enabled = true,
}: UseSSEOptions) {
const [connected, setConnected] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const sourceRef = useRef<EventSource | null>(null);
const retriesRef = useRef(0);
const connect = useCallback(() => {
if (sourceRef.current) {
sourceRef.current.close();
}
const source = new EventSource(url);
sourceRef.current = source;
source.onopen = () => {
setConnected(true);
setReconnecting(false);
retriesRef.current = 0;
};
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onMessage?.(data);
} catch {
onMessage?.(event.data);
}
};
source.onerror = (event) => {
setConnected(false);
if (source.readyState === EventSource.CLOSED) {
// Connection closed, EventSource will not auto-reconnect
retriesRef.current++;
const delay = Math.min(1000 * 2 ** retriesRef.current, 30000);
setReconnecting(true);
setTimeout(() => {
if (enabled) connect();
}, delay);
}
onError?.(event);
};
// Register named event handlers
for (const [eventName, handler] of Object.entries(events)) {
source.addEventListener(eventName, (event) => {
try {
const data = JSON.parse((event as MessageEvent).data);
handler(data);
} catch {
handler((event as MessageEvent).data);
}
});
}
}, [url, onMessage, onError, events, enabled]);
useEffect(() => {
if (!enabled) return;
connect();
return () => {
sourceRef.current?.close();
sourceRef.current = null;
};
}, [connect, enabled]);
const disconnect = useCallback(() => {
sourceRef.current?.close();
sourceRef.current = null;
setConnected(false);
}, []);
return { connected, reconnecting, disconnect };
}
Usage Examples
"use client";
import { useState, useCallback } from "react";
import { useSSE } from "@/hooks/use-sse";
interface Notification {
id: string;
title: string;
body: string;
type: "info" | "success" | "warning";
}
export function LiveNotifications({ userId }: { userId: string }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const { connected, reconnecting } = useSSE({
url: `/api/events?userId=${userId}`,
events: {
notification: useCallback((data: unknown) => {
setNotifications((prev) => [data as Notification, ...prev].slice(0, 50));
}, []),
connected: useCallback((data: unknown) => {
console.log("SSE connected:", data);
}, []),
},
});
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<span
className={`w-2 h-2 rounded-full ${
connected
? "bg-green-500"
: reconnecting
? "bg-amber-500 animate-pulse"
: "bg-red-500"
}`}
/>
<span className="text-muted-foreground">
{connected
? "Connected"
: reconnecting
? "Reconnecting..."
: "Disconnected"}
</span>
</div>
{notifications.map((n) => (
<div
key={n.id}
className={`p-3 rounded-lg text-sm border ${
n.type === "success"
? "bg-green-50 border-green-200"
: n.type === "warning"
? "bg-amber-50 border-amber-200"
: "bg-blue-50 border-blue-200"
}`}
>
<p className="font-medium">{n.title}</p>
<p className="text-muted-foreground">{n.body}</p>
</div>
))}
</div>
);
}
Trigger Events From Server Actions
// app/actions.ts
"use server";
import { sendToUser, broadcast } from "@/app/api/events/route";
export async function notifyUser(userId: string, message: string) {
sendToUser(userId, {
event: "notification",
data: {
id: crypto.randomUUID(),
title: "New notification",
body: message,
type: "info",
},
id: crypto.randomUUID(),
});
}
export async function broadcastUpdate(data: unknown) {
broadcast({
event: "update",
data,
id: crypto.randomUUID(),
});
}
Need Real-Time Updates?
We build efficient streaming architectures for live data. Contact us to discuss your project.