Server-Sent Events (SSE) provide a simple way to stream data from server to client without WebSockets.
When to Use SSE vs WebSockets
- SSE: One-way server-to-client (notifications, live feeds, AI streaming)
- WebSockets: Two-way communication (chat, collaborative editing, gaming)
SSE automatically reconnects, works over HTTP/2, and is simpler to implement.
Step 1: Basic SSE Route
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Send initial data
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: "connected" })}\n\n`)
);
// Simulate streaming data
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 1000));
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "update", count: i + 1 })}\n\n`
)
);
}
// Signal completion
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: "done" })}\n\n`)
);
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Step 2: Client-Side Hook
"use client";
import { useState, useEffect, useCallback } from "react";
interface SSEOptions {
onMessage?: (data: unknown) => void;
onError?: (error: Event) => void;
onOpen?: () => void;
}
export function useSSE(url: string, options: SSEOptions = {}) {
const [isConnected, setIsConnected] = useState(false);
const [data, setData] = useState<unknown[]>([]);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onopen = () => {
setIsConnected(true);
options.onOpen?.();
};
eventSource.onmessage = (event) => {
const parsed = JSON.parse(event.data);
setData((prev) => [...prev, parsed]);
options.onMessage?.(parsed);
if (parsed.type === "done") {
eventSource.close();
setIsConnected(false);
}
};
eventSource.onerror = (error) => {
options.onError?.(error);
setIsConnected(false);
};
return () => {
eventSource.close();
setIsConnected(false);
};
}, [url]);
return { isConnected, data };
}
Step 3: Progress Streaming
// app/api/process/route.ts
export async function POST(req: Request) {
const { items } = await req.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < items.length; i++) {
// Process each item
await processItem(items[i]);
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: "progress",
current: i + 1,
total: items.length,
percent: Math.round(((i + 1) / items.length) * 100),
item: items[i],
})}\n\n`
)
);
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: "complete" })}\n\n`)
);
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
Step 4: Progress Bar Component
"use client";
import { useState } from "react";
export function ProgressStream() {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
async function startProcessing() {
setIsProcessing(true);
setProgress(0);
const res = await fetch("/api/process", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: Array.from({ length: 50 }) }),
});
const reader = res.body?.getReader();
const decoder = new TextDecoder();
if (!reader) return;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split("\n").filter((line) => line.startsWith("data: "));
for (const line of lines) {
const data = JSON.parse(line.slice(6));
if (data.type === "progress") {
setProgress(data.percent);
setStatus(`Processing ${data.current} of ${data.total}...`);
} else if (data.type === "complete") {
setStatus("Complete!");
setIsProcessing(false);
}
}
}
}
return (
<div className="space-y-4">
<button
onClick={startProcessing}
disabled={isProcessing}
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isProcessing ? "Processing..." : "Start Processing"}
</button>
{isProcessing && (
<div>
<div className="h-3 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<p className="mt-1 text-sm text-gray-500">{status}</p>
</div>
)}
</div>
);
}
Step 5: Live Feed with EventSource
"use client";
import { useSSE } from "@/hooks/useSSE";
export function LiveFeed() {
const { isConnected, data } = useSSE("/api/stream/feed");
return (
<div>
<div className="mb-2 flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
isConnected ? "bg-green-500" : "bg-gray-400"
}`}
/>
<span className="text-sm text-gray-500">
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
<div className="space-y-2">
{data.map((item: any, i: number) => (
<div
key={i}
className="rounded-lg border p-3 animate-in slide-in-from-top-2 dark:border-gray-700"
>
<p className="text-sm">{item.message}</p>
<p className="mt-1 text-xs text-gray-500">
{new Date(item.timestamp).toLocaleTimeString()}
</p>
</div>
))}
</div>
</div>
);
}
Step 6: Named Events
// Server: Send named events
controller.enqueue(
encoder.encode(`event: notification\ndata: ${JSON.stringify(notification)}\n\n`)
);
controller.enqueue(
encoder.encode(`event: status\ndata: ${JSON.stringify(status)}\n\n`)
);
// Client: Listen to specific events
const eventSource = new EventSource("/api/events");
eventSource.addEventListener("notification", (e) => {
const data = JSON.parse(e.data);
showNotification(data);
});
eventSource.addEventListener("status", (e) => {
const data = JSON.parse(e.data);
updateStatus(data);
});
Need Real-Time Features?
We build web applications with live streaming, real-time updates, and event-driven architectures. Contact us to discuss your project.