Pedro Alonso

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

9 min read

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:

  1. Understanding SSE and when to use it over WebSockets
  2. Setting up SSE endpoints in Next.js App Router
  3. Building real-time notification systems
  4. Implementing live progress tracking
  5. Creating activity feeds and live dashboards
  6. 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:

ClientServerTraditional Pollingloop[Every 5 seconds]Server-Sent Events (SSE)Connection stays openWebSocketsBidirectional communicationRequest updatesResponse (maybe no updates)Establish SSE connectionUpdate 1Update 2Update 3Establish connectionServer messageClient messageServer messageClientServer

1.2 SSE vs WebSockets: When to Use Each

Here’s a practical comparison to help you choose:

FeatureSSEWebSockets
CommunicationServer → Client onlyBidirectional
ProtocolHTTPCustom (ws://)
Browser SupportExcellent (built-in)Excellent (requires library)
ReconnectionAutomaticManual implementation
Event TypesBuilt-in supportCustom implementation
Firewall/ProxyWorks everywhereMay be blocked
ComplexityLowModerate to High
Use CasesNotifications, feeds, progressChat, 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 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"message": "First update"}
data: {"message": "Second update"}
event: custom
data: {"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:

Terminal window
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:

Terminal window
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:

  1. export const dynamic = 'force-dynamic': Tells Next.js to always run this route dynamically, never cache it
  2. ReadableStream: Creates a stream that can send data over time
  3. TextEncoder: Converts strings to bytes for the stream
  4. SSE format: data: {JSON}\n\n - Each message must have this format
  5. 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:

Terminal window
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 logic
function 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 connections
const 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:

SSE Production Checklist
Reverse Proxy Config
Connection Limits
Message Persistence
Monitoring
Nginx: proxy_buffering off
Add timeout settings
Per-IP limits
Per-user limits
Global limits
Use Redis/DB
Message replay on reconnect
Track connection count
Monitor message rate
Alert on errors

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:

Terminal window
# Test SSE endpoint
curl -N -H "Accept: text/event-stream" http://localhost:3000/api/sse/notifications
# With authentication
curl -N -H "Accept: text/event-stream" \
-H "Authorization: Bearer your-token" \
http://localhost:3000/api/sse/notifications

Create automated tests:

__tests__/sse.test.ts
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 CaseBest ChoiceWhy
Live NotificationsSSE ✅One-way, simple, automatic reconnection
Chat ApplicationWebSockets ✅Bidirectional, low latency
Stock TickerSSE ✅Server pushes updates, client only displays
Multiplayer GameWebSockets ✅Real-time bidirectional communication
Progress BarsSSE ✅Server updates status, client displays
Collaborative EditingWebSockets ✅Multiple clients need to sync changes
Activity FeedsSSE ✅Server broadcasts updates to clients
Live DashboardSSE ✅Server pushes metrics, client visualizes
Video ChatWebRTCSpecialized 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

  1. SSE Fundamentals: Understanding when SSE is the right choice
  2. Real-Time Notifications: Building a notification center with live updates
  3. Progress Tracking: Monitoring long-running tasks in real-time
  4. Activity Feeds: Displaying live user activities
  5. 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:

  1. Add authentication: Secure endpoints with session validation
  2. Implement message persistence: Use Redis for reliable delivery
  3. Add event filtering: Let clients subscribe to specific events
  4. Build reconnection UI: Show connection status to users
  5. Monitor performance: Track connection counts and message rates

Additional Resources

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!