Real-Time Notifications with Server-Sent Events (SSE) in Next.js
Introduction
In my previous series on WebSockets with Next.js, I showed you how to build real-time applications with bidirectional communication. While WebSockets are powerful, they’re often overkill for many real-time scenarios. If you’ve ever thought “I just need to push updates to the client,” then Server-Sent Events (SSE) might be exactly what you’re looking for.
SSE is a built-in browser API that allows servers to push updates to clients over a simple HTTP connection. No complex setup, no additional libraries for the client side, and no need for a custom server configuration. It’s perfect for notifications, live updates, progress bars, and any scenario where the server needs to push data to the client without requiring the client to send data back.
In this comprehensive guide, we’ll explore how to implement SSE in Next.js 15, covering:
- Understanding SSE and when to use it over WebSockets
- Setting up SSE endpoints in Next.js App Router
- Building real-time notification systems
- Implementing live progress tracking
- Creating activity feeds and live dashboards
- Production considerations and best practices
By the end of this tutorial, you’ll be able to add real-time features to your Next.js applications with minimal complexity.
Let’s get started!
1. Understanding Server-Sent Events (SSE)
Before diving into implementation, let’s understand what SSE is and how it compares to other real-time technologies.
1.1 What is SSE?
Server-Sent Events (SSE) is a standard that allows servers to push data to web clients over HTTP. Unlike WebSockets, which provide full-duplex communication, SSE is a one-way communication channel from server to client.
Think of SSE as a one-way radio broadcast:
- The server is the radio station broadcasting updates
- Clients are radios tuning in to receive those broadcasts
- If clients need to send data back, they use regular HTTP requests
Here’s how SSE differs from traditional polling and WebSockets:
1.2 SSE vs WebSockets: When to Use Each
Here’s a practical comparison to help you choose:
Feature | SSE | WebSockets |
---|---|---|
Communication | Server → Client only | Bidirectional |
Protocol | HTTP | Custom (ws://) |
Browser Support | Excellent (built-in) | Excellent (requires library) |
Reconnection | Automatic | Manual implementation |
Event Types | Built-in support | Custom implementation |
Firewall/Proxy | Works everywhere | May be blocked |
Complexity | Low | Moderate to High |
Use Cases | Notifications, feeds, progress | Chat, games, collaboration |
Use SSE when:
- ✅ You only need server-to-client communication
- ✅ You’re building notifications or activity feeds
- ✅ You need automatic reconnection
- ✅ You want simplicity and less code
- ✅ You’re tracking progress or status updates
- ✅ You need to work through restrictive firewalls
Use WebSockets when:
- ❌ You need client-to-server real-time communication
- ❌ You’re building chat or multiplayer games
- ❌ You need low-latency bidirectional data
- ❌ You require complex message patterns
- ❌ You’re building collaborative editing tools
1.3 How SSE Works
SSE uses a special content type (text/event-stream
) and keeps an HTTP connection open. The server can send messages at any time:
HTTP/1.1 200 OKContent-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
data: {"message": "First update"}
data: {"message": "Second update"}
event: customdata: {"message": "Custom event"}
The browser’s built-in EventSource
API handles:
- Parsing the stream
- Automatic reconnection (with exponential backoff)
- Event dispatching
This means you get robust real-time functionality with minimal effort!
2. Setting Up Your Next.js Project
Let’s create a new Next.js 15 project and set up our first SSE endpoint.
2.1 Creating a New Next.js Project
Open your terminal and run:
npx create-next-app@latest sse-nextjs-demo
Choose the following options:
✔ Would you like to use TypeScript? … Yes✔ Would you like to use ESLint? … Yes✔ Would you like to use Tailwind CSS? … Yes✔ Would you like to use `src/` directory? … Yes✔ Would you like to use App Router? … Yes✔ Would you like to customize the default import alias? … No
Navigate to your project:
cd sse-nextjs-demo
2.2 Understanding Next.js Route Handlers for SSE
In Next.js 15 with the App Router, we use Route Handlers to create SSE endpoints. Route Handlers are server-side functions that handle HTTP requests.
The key to SSE in Next.js is using the ReadableStream
API to send continuous updates to the client.
2.3 Creating Your First SSE Endpoint
Let’s create a simple SSE endpoint that sends the current time every second. Create src/app/api/sse/time/route.ts
:
import { NextRequest } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) { // Create a readable stream const stream = new ReadableStream({ start(controller) { // Send initial connection message const encoder = new TextEncoder(); controller.enqueue( encoder.encode(`data: ${JSON.stringify({ message: 'Connected!' })}\n\n`) );
// Send time updates every second const interval = setInterval(() => { const data = { time: new Date().toISOString(), timestamp: Date.now(), };
controller.enqueue( encoder.encode(`data: ${JSON.stringify(data)}\n\n`) ); }, 1000);
// Cleanup on connection close req.signal.addEventListener('abort', () => { clearInterval(interval); controller.close(); }); }, });
// Return the stream with proper headers return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', // Disable buffering for Nginx }, });}
Let’s break down what this code does:
export const dynamic = 'force-dynamic'
: Tells Next.js to always run this route dynamically, never cache itReadableStream
: Creates a stream that can send data over timeTextEncoder
: Converts strings to bytes for the stream- SSE format:
data: {JSON}\n\n
- Each message must have this format - Cleanup: Listens for connection abort to stop sending updates
2.4 Creating a Client Component to Consume SSE
Now let’s create a React component to receive these updates. Create src/components/TimeDisplay.tsx
:
'use client';
import { useEffect, useState } from 'react';
export default function TimeDisplay() { const [time, setTime] = useState<string>(''); const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
useEffect(() => { // Create EventSource connection const eventSource = new EventSource('/api/sse/time');
eventSource.onopen = () => { setStatus('connected'); console.log('SSE connection established'); };
eventSource.onmessage = (event) => { const data = JSON.parse(event.data); if (data.time) { setTime(data.time); } };
eventSource.onerror = (error) => { console.error('SSE error:', error); setStatus('disconnected'); eventSource.close(); };
// Cleanup on unmount return () => { eventSource.close(); }; }, []);
return ( <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm"> <div className="mb-4 flex items-center justify-between"> <h2 className="text-xl font-bold">Live Time</h2> <div className="flex items-center gap-2"> <div className={`h-3 w-3 rounded-full ${ status === 'connected' ? 'bg-green-500' : status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500' }`} /> <span className="text-sm text-gray-600 capitalize">{status}</span> </div> </div> <p className="font-mono text-2xl text-gray-900"> {time || 'Connecting...'} </p> </div> );}
2.5 Adding to Your Home Page
Update src/app/page.tsx
:
import TimeDisplay from '@/components/TimeDisplay';
export default function Home() { return ( <main className="min-h-screen bg-gray-50 p-8"> <div className="mx-auto max-w-4xl"> <h1 className="mb-8 text-4xl font-bold">SSE Demo</h1> <TimeDisplay /> </div> </main> );}
Now run your development server:
npm run dev
Visit http://localhost:3000
and you should see the time updating every second! The green dot indicates an active connection.
3. Building a Real-Time Notification System
Now that we understand the basics, let’s build something more practical: a real-time notification system.
3.1 Creating a Notification Service
First, let’s create a simple in-memory notification service. In production, you’d use Redis or a database. Create src/lib/notificationService.ts
:
export interface Notification { id: string; type: 'info' | 'success' | 'warning' | 'error'; title: string; message: string; timestamp: number; read: boolean;}
class NotificationService { private listeners: Set<(notification: Notification) => void> = new Set(); private notifications: Notification[] = [];
subscribe(callback: (notification: Notification) => void) { this.listeners.add(callback); return () => this.listeners.delete(callback); }
emit(notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) { const fullNotification: Notification = { ...notification, id: Math.random().toString(36).substr(2, 9), timestamp: Date.now(), read: false, };
this.notifications.push(fullNotification); this.listeners.forEach((callback) => callback(fullNotification)); }
getAll() { return this.notifications; }
markAsRead(id: string) { const notification = this.notifications.find((n) => n.id === id); if (notification) { notification.read = true; } }}
export const notificationService = new NotificationService();
3.2 Creating the Notification SSE Endpoint
Create src/app/api/sse/notifications/route.ts
:
import { NextRequest } from 'next/server';import { notificationService, Notification } from '@/lib/notificationService';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) { const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder();
// Send existing notifications const existingNotifications = notificationService.getAll(); existingNotifications.forEach((notification) => { controller.enqueue( encoder.encode(`data: ${JSON.stringify(notification)}\n\n`) ); });
// Subscribe to new notifications const unsubscribe = notificationService.subscribe( (notification: Notification) => { try { controller.enqueue( encoder.encode(`data: ${JSON.stringify(notification)}\n\n`) ); } catch (error) { console.error('Error sending notification:', error); } } );
// Send heartbeat every 30 seconds to keep connection alive const heartbeat = setInterval(() => { try { controller.enqueue(encoder.encode(`: heartbeat\n\n`)); } catch (error) { clearInterval(heartbeat); } }, 30000);
// Cleanup on connection close req.signal.addEventListener('abort', () => { clearInterval(heartbeat); unsubscribe(); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }, });}
3.3 Creating an API to Trigger Notifications
Create src/app/api/notifications/create/route.ts
:
import { NextRequest, NextResponse } from 'next/server';import { notificationService } from '@/lib/notificationService';
export async function POST(req: NextRequest) { try { const body = await req.json(); const { type, title, message } = body;
if (!type || !title || !message) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); }
notificationService.emit({ type, title, message });
return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( { error: 'Failed to create notification' }, { status: 500 } ); }}
3.4 Building the Notification UI Component
Create src/components/NotificationCenter.tsx
:
'use client';
import { useEffect, useState } from 'react';import { Notification } from '@/lib/notificationService';
export default function NotificationCenter() { const [notifications, setNotifications] = useState<Notification[]>([]); const [isOpen, setIsOpen] = useState(false); const unreadCount = notifications.filter((n) => !n.read).length;
useEffect(() => { const eventSource = new EventSource('/api/sse/notifications');
eventSource.onmessage = (event) => { const notification = JSON.parse(event.data); setNotifications((prev) => [notification, ...prev].slice(0, 10)); // Keep last 10 };
eventSource.onerror = () => { eventSource.close(); };
return () => { eventSource.close(); }; }, []);
const getIcon = (type: Notification['type']) => { switch (type) { case 'success': return '✅'; case 'error': return '❌'; case 'warning': return '⚠️'; default: return 'ℹ️'; } };
const getColor = (type: Notification['type']) => { switch (type) { case 'success': return 'border-green-500 bg-green-50'; case 'error': return 'border-red-500 bg-red-50'; case 'warning': return 'border-yellow-500 bg-yellow-50'; default: return 'border-blue-500 bg-blue-50'; } };
return ( <div className="relative"> {/* Notification Bell */} <button onClick={() => setIsOpen(!isOpen)} className="relative rounded-full p-2 hover:bg-gray-100" > <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> </svg> {unreadCount > 0 && ( <span className="absolute right-0 top-0 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white"> {unreadCount} </span> )} </button>
{/* Notification Panel */} {isOpen && ( <div className="absolute right-0 top-12 z-50 w-80 rounded-lg border border-gray-200 bg-white shadow-xl"> <div className="border-b border-gray-200 p-4"> <h3 className="font-bold">Notifications</h3> </div> <div className="max-h-96 overflow-y-auto"> {notifications.length === 0 ? ( <p className="p-4 text-center text-gray-500">No notifications</p> ) : ( notifications.map((notification) => ( <div key={notification.id} className={`border-l-4 p-4 ${getColor(notification.type)} ${ notification.read ? 'opacity-60' : '' }`} > <div className="flex items-start gap-3"> <span className="text-2xl">{getIcon(notification.type)}</span> <div className="flex-1"> <h4 className="font-semibold">{notification.title}</h4> <p className="text-sm text-gray-600"> {notification.message} </p> <p className="mt-1 text-xs text-gray-400"> {new Date(notification.timestamp).toLocaleTimeString()} </p> </div> </div> </div> )) )} </div> </div> )} </div> );}
3.5 Creating a Test Dashboard
Create src/app/notifications/page.tsx
:
'use client';
import { useState } from 'react';import NotificationCenter from '@/components/NotificationCenter';
export default function NotificationsPage() { const [isLoading, setIsLoading] = useState(false);
const sendTestNotification = async (type: string) => { setIsLoading(true); try { await fetch('/api/notifications/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, title: `Test ${type} notification`, message: `This is a ${type} notification sent at ${new Date().toLocaleTimeString()}`, }), }); } catch (error) { console.error('Error sending notification:', error); } finally { setIsLoading(false); } };
return ( <div className="min-h-screen bg-gray-50"> {/* Header with Notification Center */} <header className="border-b border-gray-200 bg-white"> <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4"> <h1 className="text-2xl font-bold">Notification Demo</h1> <NotificationCenter /> </div> </header>
{/* Main Content */} <main className="mx-auto max-w-7xl p-8"> <div className="rounded-lg bg-white p-6 shadow-sm"> <h2 className="mb-4 text-xl font-bold">Send Test Notifications</h2> <p className="mb-6 text-gray-600"> Click the buttons below to trigger different types of notifications </p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <button onClick={() => sendTestNotification('info')} disabled={isLoading} className="rounded-lg bg-blue-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-blue-600 disabled:opacity-50" > Info Notification </button> <button onClick={() => sendTestNotification('success')} disabled={isLoading} className="rounded-lg bg-green-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-green-600 disabled:opacity-50" > Success Notification </button> <button onClick={() => sendTestNotification('warning')} disabled={isLoading} className="rounded-lg bg-yellow-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-yellow-600 disabled:opacity-50" > Warning Notification </button> <button onClick={() => sendTestNotification('error')} disabled={isLoading} className="rounded-lg bg-red-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-red-600 disabled:opacity-50" > Error Notification </button> </div> </div> </main> </div> );}
Now when you visit /notifications
, you can test the notification system! Click the buttons to send notifications and watch them appear in real-time in the notification center.
4. Building a Live Progress Tracker
Another common use case for SSE is tracking long-running operations. Let’s build a progress tracker for file uploads or data processing.
4.1 Creating a Progress Service
Create src/lib/progressService.ts
:
export interface ProgressUpdate { taskId: string; progress: number; // 0-100 status: 'pending' | 'processing' | 'completed' | 'failed'; message: string; currentStep?: string;}
class ProgressService { private listeners: Map<string, Set<(update: ProgressUpdate) => void>> = new Map(); private tasks: Map<string, ProgressUpdate> = new Map();
subscribe(taskId: string, callback: (update: ProgressUpdate) => void) { if (!this.listeners.has(taskId)) { this.listeners.set(taskId, new Set()); } this.listeners.get(taskId)!.add(callback);
// Send current state immediately if task exists const currentTask = this.tasks.get(taskId); if (currentTask) { callback(currentTask); }
return () => { this.listeners.get(taskId)?.delete(callback); if (this.listeners.get(taskId)?.size === 0) { this.listeners.delete(taskId); } }; }
updateProgress(update: ProgressUpdate) { this.tasks.set(update.taskId, update);
const listeners = this.listeners.get(update.taskId); if (listeners) { listeners.forEach((callback) => callback(update)); }
// Clean up completed tasks after 1 minute if (update.status === 'completed' || update.status === 'failed') { setTimeout(() => { this.tasks.delete(update.taskId); }, 60000); } }
getTask(taskId: string) { return this.tasks.get(taskId); }}
export const progressService = new ProgressService();
4.2 Creating the Progress SSE Endpoint
Create src/app/api/sse/progress/[taskId]/route.ts
:
import { NextRequest } from 'next/server';import { progressService, ProgressUpdate } from '@/lib/progressService';
export const dynamic = 'force-dynamic';
interface RouteContext { params: { taskId: string; };}
export async function GET(req: NextRequest, { params }: RouteContext) { const taskId = params.taskId;
const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder();
// Subscribe to progress updates const unsubscribe = progressService.subscribe( taskId, (update: ProgressUpdate) => { try { controller.enqueue( encoder.encode(`data: ${JSON.stringify(update)}\n\n`) );
// Close connection when task is done if (update.status === 'completed' || update.status === 'failed') { setTimeout(() => { controller.close(); }, 1000); } } catch (error) { console.error('Error sending progress update:', error); } } );
// Cleanup on connection close req.signal.addEventListener('abort', () => { unsubscribe(); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }, });}
4.3 Creating a Task Simulation Endpoint
Create src/app/api/tasks/start/route.ts
:
import { NextRequest, NextResponse } from 'next/server';import { progressService } from '@/lib/progressService';
export async function POST(req: NextRequest) { const { taskName } = await req.json(); const taskId = Math.random().toString(36).substr(2, 9);
// Simulate a long-running task simulateTask(taskId, taskName);
return NextResponse.json({ taskId });}
async function simulateTask(taskId: string, taskName: string) { const steps = [ { step: 'Initializing', progress: 0 }, { step: 'Processing data', progress: 20 }, { step: 'Analyzing content', progress: 40 }, { step: 'Generating results', progress: 60 }, { step: 'Optimizing output', progress: 80 }, { step: 'Finalizing', progress: 95 }, ];
// Initial update progressService.updateProgress({ taskId, progress: 0, status: 'processing', message: `Starting task: ${taskName}`, currentStep: 'Initializing', });
// Simulate progress for (const { step, progress } of steps) { await new Promise((resolve) => setTimeout(resolve, 1000));
progressService.updateProgress({ taskId, progress, status: 'processing', message: `${step}...`, currentStep: step, }); }
// Complete await new Promise((resolve) => setTimeout(resolve, 500)); progressService.updateProgress({ taskId, progress: 100, status: 'completed', message: `Task "${taskName}" completed successfully!`, currentStep: 'Done', });}
4.4 Creating the Progress UI Component
Create src/components/ProgressTracker.tsx
:
'use client';
import { useEffect, useState } from 'react';import { ProgressUpdate } from '@/lib/progressService';
interface ProgressTrackerProps { taskId: string; onComplete?: () => void;}
export default function ProgressTracker({ taskId, onComplete }: ProgressTrackerProps) { const [progress, setProgress] = useState<ProgressUpdate | null>(null);
useEffect(() => { const eventSource = new EventSource(`/api/sse/progress/${taskId}`);
eventSource.onmessage = (event) => { const update: ProgressUpdate = JSON.parse(event.data); setProgress(update);
if (update.status === 'completed' && onComplete) { onComplete(); } };
eventSource.onerror = () => { eventSource.close(); };
return () => { eventSource.close(); }; }, [taskId, onComplete]);
if (!progress) { return <div>Connecting...</div>; }
const getStatusColor = () => { switch (progress.status) { case 'completed': return 'bg-green-500'; case 'failed': return 'bg-red-500'; case 'processing': return 'bg-blue-500'; default: return 'bg-gray-500'; } };
return ( <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm"> <div className="mb-4 flex items-center justify-between"> <h3 className="font-bold">Task Progress</h3> <span className={`rounded-full px-3 py-1 text-sm font-semibold text-white ${ progress.status === 'completed' ? 'bg-green-500' : progress.status === 'failed' ? 'bg-red-500' : 'bg-blue-500' }`} > {progress.status} </span> </div>
<div className="mb-2 flex items-center justify-between text-sm"> <span className="text-gray-600">{progress.currentStep}</span> <span className="font-semibold">{progress.progress}%</span> </div>
<div className="mb-4 h-3 w-full overflow-hidden rounded-full bg-gray-200"> <div className={`h-full transition-all duration-300 ${getStatusColor()}`} style={{ width: `${progress.progress}%` }} /> </div>
<p className="text-sm text-gray-600">{progress.message}</p>
{progress.status === 'completed' && ( <div className="mt-4 flex items-center gap-2 text-green-600"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /> </svg> <span className="font-semibold">Task completed!</span> </div> )} </div> );}
4.5 Creating the Progress Demo Page
Create src/app/progress/page.tsx
:
'use client';
import { useState } from 'react';import ProgressTracker from '@/components/ProgressTracker';
export default function ProgressPage() { const [tasks, setTasks] = useState<{ id: string; name: string }[]>([]); const [taskName, setTaskName] = useState(''); const [isStarting, setIsStarting] = useState(false);
const startTask = async () => { if (!taskName.trim()) return;
setIsStarting(true); try { const response = await fetch('/api/tasks/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskName }), });
const { taskId } = await response.json(); setTasks((prev) => [...prev, { id: taskId, name: taskName }]); setTaskName(''); } catch (error) { console.error('Error starting task:', error); } finally { setIsStarting(false); } };
return ( <div className="min-h-screen bg-gray-50 p-8"> <div className="mx-auto max-w-4xl"> <h1 className="mb-8 text-4xl font-bold">Progress Tracker Demo</h1>
<div className="mb-8 rounded-lg bg-white p-6 shadow-sm"> <h2 className="mb-4 text-xl font-bold">Start New Task</h2> <div className="flex gap-4"> <input type="text" value={taskName} onChange={(e) => setTaskName(e.target.value)} placeholder="Enter task name..." className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none" onKeyPress={(e) => e.key === 'Enter' && startTask()} /> <button onClick={startTask} disabled={isStarting || !taskName.trim()} className="rounded-lg bg-blue-500 px-6 py-2 font-semibold text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50" > {isStarting ? 'Starting...' : 'Start Task'} </button> </div> </div>
<div className="space-y-4"> {tasks.length === 0 ? ( <p className="text-center text-gray-500"> No tasks running. Start a new task above. </p> ) : ( tasks.map((task) => ( <div key={task.id}> <h3 className="mb-2 font-semibold">{task.name}</h3> <ProgressTracker taskId={task.id} /> </div> )) )} </div> </div> </div> );}
Now you can visit /progress
to see live progress tracking in action!
5. Building a Live Activity Feed
Let’s create one more practical example: a live activity feed showing user actions in real-time.
5.1 Creating an Activity Service
Create src/lib/activityService.ts
:
export interface Activity { id: string; userId: string; userName: string; action: string; details: string; timestamp: number; type: 'user_joined' | 'post_created' | 'comment_added' | 'file_uploaded' | 'task_completed';}
class ActivityService { private listeners: Set<(activity: Activity) => void> = new Set(); private activities: Activity[] = [];
subscribe(callback: (activity: Activity) => void) { this.listeners.add(callback); return () => this.listeners.delete(callback); }
addActivity(activity: Omit<Activity, 'id' | 'timestamp'>) { const fullActivity: Activity = { ...activity, id: Math.random().toString(36).substr(2, 9), timestamp: Date.now(), };
this.activities.unshift(fullActivity); // Add to beginning this.activities = this.activities.slice(0, 50); // Keep last 50
this.listeners.forEach((callback) => callback(fullActivity)); }
getRecent(limit: number = 20) { return this.activities.slice(0, limit); }}
export const activityService = new ActivityService();
5.2 Creating the Activity Feed SSE Endpoint
Create src/app/api/sse/activity/route.ts
:
import { NextRequest } from 'next/server';import { activityService, Activity } from '@/lib/activityService';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) { const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder();
// Send recent activities const recentActivities = activityService.getRecent(10); recentActivities.forEach((activity) => { controller.enqueue( encoder.encode(`data: ${JSON.stringify(activity)}\n\n`) ); });
// Subscribe to new activities const unsubscribe = activityService.subscribe((activity: Activity) => { try { controller.enqueue( encoder.encode(`data: ${JSON.stringify(activity)}\n\n`) ); } catch (error) { console.error('Error sending activity:', error); } });
// Cleanup req.signal.addEventListener('abort', () => { unsubscribe(); controller.close(); }); }, });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }, });}
5.3 Creating the Activity Feed Component
Create src/components/ActivityFeed.tsx
:
'use client';
import { useEffect, useState } from 'react';import { Activity } from '@/lib/activityService';
export default function ActivityFeed() { const [activities, setActivities] = useState<Activity[]>([]);
useEffect(() => { const eventSource = new EventSource('/api/sse/activity');
eventSource.onmessage = (event) => { const activity: Activity = JSON.parse(event.data); setActivities((prev) => { const exists = prev.some((a) => a.id === activity.id); if (exists) return prev; return [activity, ...prev].slice(0, 20); }); };
return () => { eventSource.close(); }; }, []);
const getActivityIcon = (type: Activity['type']) => { switch (type) { case 'user_joined': return '👋'; case 'post_created': return '📝'; case 'comment_added': return '💬'; case 'file_uploaded': return '📁'; case 'task_completed': return '✅'; default: return '📌'; } };
const getActivityColor = (type: Activity['type']) => { switch (type) { case 'user_joined': return 'bg-blue-100 text-blue-800'; case 'post_created': return 'bg-green-100 text-green-800'; case 'comment_added': return 'bg-purple-100 text-purple-800'; case 'file_uploaded': return 'bg-yellow-100 text-yellow-800'; case 'task_completed': return 'bg-indigo-100 text-indigo-800'; default: return 'bg-gray-100 text-gray-800'; } };
const getRelativeTime = (timestamp: number) => { const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 60) return 'just now'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; };
return ( <div className="rounded-lg border border-gray-200 bg-white shadow-sm"> <div className="border-b border-gray-200 p-4"> <h2 className="text-xl font-bold">Live Activity Feed</h2> </div> <div className="max-h-96 overflow-y-auto"> {activities.length === 0 ? ( <p className="p-8 text-center text-gray-500"> No recent activity. Start interacting! </p> ) : ( <div className="divide-y divide-gray-100"> {activities.map((activity) => ( <div key={activity.id} className="p-4 transition-colors hover:bg-gray-50"> <div className="flex items-start gap-3"> <span className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-xl ${getActivityColor( activity.type )}`} > {getActivityIcon(activity.type)} </span> <div className="flex-1 min-w-0"> <p className="text-sm"> <span className="font-semibold">{activity.userName}</span>{' '} {activity.action} </p> <p className="text-sm text-gray-600 truncate"> {activity.details} </p> <p className="mt-1 text-xs text-gray-400"> {getRelativeTime(activity.timestamp)} </p> </div> </div> </div> ))} </div> )} </div> </div> );}
5.4 Creating Activity Simulation
Create src/app/api/activity/simulate/route.ts
:
import { NextResponse } from 'next/server';import { activityService } from '@/lib/activityService';
const users = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'];const actions = [ { type: 'user_joined' as const, action: 'joined the workspace', details: 'Welcome!' }, { type: 'post_created' as const, action: 'created a new post', details: 'Check out this amazing content!' }, { type: 'comment_added' as const, action: 'commented on', details: 'Great work on this feature!' }, { type: 'file_uploaded' as const, action: 'uploaded a file', details: 'design-mockup-v3.pdf' }, { type: 'task_completed' as const, action: 'completed a task', details: 'Fix login bug' },];
export async function POST() { const user = users[Math.floor(Math.random() * users.length)]; const activity = actions[Math.floor(Math.random() * actions.length)];
activityService.addActivity({ userId: user.toLowerCase(), userName: user, ...activity, });
return NextResponse.json({ success: true });}
5.5 Creating the Activity Demo Page
Create src/app/activity/page.tsx
:
'use client';
import { useState } from 'react';import ActivityFeed from '@/components/ActivityFeed';
export default function ActivityPage() { const [isSimulating, setIsSimulating] = useState(false);
const simulateActivity = async () => { setIsSimulating(true); try { await fetch('/api/activity/simulate', { method: 'POST' }); } catch (error) { console.error('Error simulating activity:', error); } finally { setIsSimulating(false); } };
const startAutoSimulation = () => { const interval = setInterval(async () => { await fetch('/api/activity/simulate', { method: 'POST' }); }, 3000);
return () => clearInterval(interval); };
return ( <div className="min-h-screen bg-gray-50 p-8"> <div className="mx-auto max-w-4xl"> <h1 className="mb-8 text-4xl font-bold">Activity Feed Demo</h1>
<div className="mb-8 grid gap-4 md:grid-cols-2"> <button onClick={simulateActivity} disabled={isSimulating} className="rounded-lg bg-blue-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-blue-600 disabled:opacity-50" > {isSimulating ? 'Simulating...' : 'Simulate Activity'} </button> <button onClick={startAutoSimulation} className="rounded-lg bg-green-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-green-600" > Start Auto Simulation </button> </div>
<ActivityFeed /> </div> </div> );}
6. Production Considerations and Best Practices
Now that we’ve built several SSE implementations, let’s discuss important considerations for production deployments.
6.1 Connection Management
Handle reconnections gracefully:
// Enhanced EventSource with reconnection logicfunction useSSE<T>(url: string, onMessage: (data: T) => void) { useEffect(() => { let eventSource: EventSource | null = null; let reconnectTimeout: NodeJS.Timeout;
const connect = () => { eventSource = new EventSource(url);
eventSource.onopen = () => { console.log('SSE connected'); };
eventSource.onmessage = (event) => { const data = JSON.parse(event.data); onMessage(data); };
eventSource.onerror = () => { eventSource?.close(); // Reconnect after 5 seconds reconnectTimeout = setTimeout(connect, 5000); }; };
connect();
return () => { eventSource?.close(); clearTimeout(reconnectTimeout); }; }, [url, onMessage]);}
6.2 Server Resource Management
Implement connection limits:
// Track active connectionsconst activeConnections = new Map<string, number>();
export async function GET(req: NextRequest) { const ip = req.ip || 'unknown'; const current = activeConnections.get(ip) || 0;
// Limit to 3 connections per IP if (current >= 3) { return new Response('Too many connections', { status: 429 }); }
activeConnections.set(ip, current + 1);
const stream = new ReadableStream({ start(controller) { // ... SSE logic
req.signal.addEventListener('abort', () => { activeConnections.set(ip, (activeConnections.get(ip) || 1) - 1); controller.close(); }); }, });
return new Response(stream, { /* headers */ });}
6.3 Message Queuing for Reliability
Use Redis for distributed systems:
import { Redis } from '@upstash/redis';
const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!,});
export async function GET(req: NextRequest) { const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder();
// Subscribe to Redis pub/sub const subscriber = redis.duplicate(); await subscriber.subscribe('notifications', (message) => { controller.enqueue( encoder.encode(`data: ${JSON.stringify(message)}\n\n`) ); });
req.signal.addEventListener('abort', async () => { await subscriber.unsubscribe('notifications'); controller.close(); }); }, });
return new Response(stream, { /* headers */ });}
6.4 Authentication and Authorization
Secure your SSE endpoints:
import { getServerSession } from 'next-auth';
export async function GET(req: NextRequest) { const session = await getServerSession();
if (!session?.user) { return new Response('Unauthorized', { status: 401 }); }
const userId = session.user.id;
const stream = new ReadableStream({ start(controller) { // Only send events for this user const unsubscribe = eventService.subscribe(userId, (event) => { // ... send event });
req.signal.addEventListener('abort', () => { unsubscribe(); controller.close(); }); }, });
return new Response(stream, { /* headers */ });}
6.5 Error Handling and Monitoring
Implement comprehensive error tracking:
export async function GET(req: NextRequest) { const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder();
try { // SSE logic here
// Send error events to client const sendError = (error: Error) => { controller.enqueue( encoder.encode( `event: error\ndata: ${JSON.stringify({ error: error.message })}\n\n` ) ); };
// Monitor connection health const healthCheck = setInterval(() => { controller.enqueue(encoder.encode(': ping\n\n')); }, 30000);
req.signal.addEventListener('abort', () => { clearInterval(healthCheck); console.log('SSE connection closed'); controller.close(); }); } catch (error) { console.error('SSE error:', error); controller.error(error); } }, });
return new Response(stream, { /* headers */ });}
6.6 Deployment Considerations
Important points for production:
Nginx configuration for SSE:
location /api/sse/ { proxy_pass http://nextjs:3000; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_cache off; proxy_read_timeout 86400s; chunked_transfer_encoding off;}
6.7 Testing SSE Implementations
Use cURL for testing:
# Test SSE endpointcurl -N -H "Accept: text/event-stream" http://localhost:3000/api/sse/notifications
# With authenticationcurl -N -H "Accept: text/event-stream" \ -H "Authorization: Bearer your-token" \ http://localhost:3000/api/sse/notifications
Create automated tests:
import { createMocks } from 'node-mocks-http';
describe('SSE Endpoint', () => { it('should send events correctly', async () => { const { req, res } = createMocks({ method: 'GET', });
const response = await GET(req); const reader = response.body?.getReader();
const { value } = await reader!.read(); const text = new TextDecoder().decode(value);
expect(text).toContain('data:'); });});
7. Comparison: SSE vs WebSockets in Real Applications
Let’s look at when each technology shines with real-world examples:
7.1 Use Case Comparison
Use Case | Best Choice | Why |
---|---|---|
Live Notifications | SSE ✅ | One-way, simple, automatic reconnection |
Chat Application | WebSockets ✅ | Bidirectional, low latency |
Stock Ticker | SSE ✅ | Server pushes updates, client only displays |
Multiplayer Game | WebSockets ✅ | Real-time bidirectional communication |
Progress Bars | SSE ✅ | Server updates status, client displays |
Collaborative Editing | WebSockets ✅ | Multiple clients need to sync changes |
Activity Feeds | SSE ✅ | Server broadcasts updates to clients |
Live Dashboard | SSE ✅ | Server pushes metrics, client visualizes |
Video Chat | WebRTC | Specialized for media streaming |
7.2 Performance Comparison
// SSE: Simple overhead// - Single HTTP connection per client// - Text-based protocol// - Automatic reconnection built-in
// WebSockets: More complex but flexible// - Upgrade from HTTP to WS protocol// - Binary and text support// - Manual reconnection logic needed
8. Conclusion
Congratulations! You’ve learned how to implement Server-Sent Events in Next.js 15 for various real-time scenarios.
What We Covered
- SSE Fundamentals: Understanding when SSE is the right choice
- Real-Time Notifications: Building a notification center with live updates
- Progress Tracking: Monitoring long-running tasks in real-time
- Activity Feeds: Displaying live user activities
- Production Best Practices: Security, scaling, and deployment
Key Takeaways
- SSE is simpler than WebSockets for server-to-client communication
- Built-in browser support means no additional client libraries needed
- Automatic reconnection provides resilience out of the box
- Perfect for notifications, feeds, and progress tracking
- Works well with existing HTTP infrastructure
When to Choose SSE
Use Server-Sent Events when:
- ✅ You only need server-to-client updates
- ✅ You want simplicity and less code
- ✅ You need automatic reconnection
- ✅ You’re building notifications or activity feeds
- ✅ You want to avoid WebSocket complexity
When to Use WebSockets Instead
Consider WebSockets (see my WebSocket series) when:
- ❌ You need bidirectional real-time communication
- ❌ You’re building chat, games, or collaborative tools
- ❌ You need very low latency
- ❌ You require complex message patterns
Next Steps
To enhance your SSE implementations:
- Add authentication: Secure endpoints with session validation
- Implement message persistence: Use Redis for reliable delivery
- Add event filtering: Let clients subscribe to specific events
- Build reconnection UI: Show connection status to users
- Monitor performance: Track connection counts and message rates
Additional Resources
- MDN: Server-Sent Events
- Next.js Route Handlers
- WebSockets Series Part 1: For bidirectional communication
- Stripe Integration Guide: Real-world API implementation
SSE provides a perfect balance between functionality and simplicity. It’s the right tool when you need real-time updates without the complexity of WebSockets. Whether you’re building a notification system, progress tracker, or activity feed, SSE gives you production-ready real-time features with minimal code.
Happy coding, and may your streams stay connected! 🚀
Enjoyed this article? Subscribe for more!
Related Articles

Redis Queues and Pub/Sub in Next.js - A Practical Guide
Learn how to use Redis lists, queues, and pub/sub patterns in Next.js for real-time features and background processing

From Idea to MVP: Building FastForward IQ with Next.js
Learn about From Idea to MVP: Building FastForward IQ with Next.js

Background Processing in Next.js Part 1
Learn about Background Processing in Next.js Part 1

Advanced Background Processing in Next.js Part 2
Learn about Advanced Background Processing in Next.js Part 2