Real-time updates from NestJS to Next.js with Server-Sent Events
A step-by-step tutorial: expose an SSE endpoint from NestJS with an RxJS Subject, consume it in Next.js with a small useSse hook, and push events from any service in your backend. Simpler than WebSockets when the data only flows one way.
This tutorial will guide you through implementing a real-time communication channel where your NestJS backend can actively push data to your Next.js frontend. We will use Server-Sent Events (SSE), a simple and effective technology for one-way, server-to-client communication, built directly on top of HTTP.
This is ideal for features like live notifications, status updates, or feeding real-time data to a dashboard.
Part 1: Backend setup (NestJS)
Our goal is to create a dedicated endpoint in our api application that a client can subscribe to for a stream of events.
Step 1.1: Create the Events Module
First, we’ll create a new module named events to keep our code organized.
mkdir -p apps/api/src/modules/events
Step 1.2: Create the Events Service
This service will manage the event stream using an RxJS Subject, which acts as the central channel for our messages. Other parts of your application will use this service to send new events.
Create the file apps/api/src/modules/events/events.service.ts:
import { Injectable } from '@nestjs/common';
import { Observable, Subject } from 'rxjs';
export interface MessageEvent {
data: string | object;
}
@Injectable()
export class EventsService {
private readonly events = new Subject<MessageEvent>();
// Called by the controller to get the event stream
getEvents(): Observable<MessageEvent> {
return this.events.asObservable();
}
// Called by other services to push a new event into the stream
emitEvent(event: MessageEvent) {
this.events.next(event);
}
}
Step 1.3: Create the Events Controller
The controller exposes the public-facing endpoints. We need one for the client to connect to (/sse) and a helper endpoint (/emit) to make testing easy.
Create the file apps/api/src/modules/events/events.controller.ts:
import { Controller, Sse, Post, Body } from '@nestjs/common';
import { EventsService, MessageEvent } from './events.service';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Controller('events')
export class EventsController {
constructor(private readonly eventsService: EventsService) {}
@Sse('sse')
sse(): Observable<MessageEvent> {
return this.eventsService.getEvents().pipe(
// SSE data must be in the format { data: your_data }
map((event: MessageEvent) => ({ data: event.data }))
);
}
@Post('emit')
emitEvent(@Body() body: { message: string }) {
this.eventsService.emitEvent({ data: { content: body.message, timestamp: new Date() } });
return { success: true };
}
}
Step 1.4: Define the Events Module
Now, we tie the service and controller together in a module file.
Create apps/api/src/modules/events/events.module.ts:
import { Module } from '@nestjs/common';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
@Module({
controllers: [EventsController],
providers: [EventsService],
// We export the service so other modules in our app can inject it and emit events
exports: [EventsService],
})
export class EventsModule {}
Step 1.5: Activate the Module in the App
Finally, import the EventsModule into your main AppModule to make it active.
Modify apps/api/src/app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
// Import the new module
import { EventsModule } from './modules/events/events.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// ...your other modules
// Add the new module to the imports array
EventsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Backend setup is complete! Your API can now send events.
Part 2: Frontend setup (Next.js)
Now let’s configure the web application to listen for these events and display them.
Step 2.1: Create a Reusable SSE Hook
A custom React hook is the perfect way to manage the EventSource connection lifecycle.
First, create a hooks directory:
mkdir -p apps/web/hooks
Next, create the file apps/web/hooks/use-sse.ts:
import { useState, useEffect } from 'react';
// A generic hook for Server-Sent Events
export function useSse<T>(url: string, initialValue: T) {
const [data, setData] = useState<T>(initialValue);
useEffect(() => {
// The URL should point to your NestJS SSE endpoint
const eventSource = new EventSource(url);
eventSource.onopen = () => {
console.log('SSE connection opened.');
};
eventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data);
setData(parsedData);
} catch (error) {
console.error('Failed to parse SSE data:', error);
}
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
// The browser will automatically try to reconnect on most errors.
// You can close it here if you want to stop retrying on specific errors.
eventSource.close();
};
// Cleanup when the component unmounts
return () => {
console.log('Closing SSE connection.');
eventSource.close();
};
}, [url]); // Re-run effect if URL changes
return data;
}
Step 2.2: Create the UI Component
Let’s create a component that uses our new hook to display the live data and includes a button for testing.
Create the file apps/web/components/real-time-updates.tsx:
'use client';
import { useSse } from '@/hooks/use-sse';
import { useState } from 'react';
// Define the structure of the data you expect from the server
interface SseData {
content: string;
timestamp: string;
}
export function RealTimeUpdates() {
// IMPORTANT: Replace with your actual API URL.
// It's best to use an environment variable for this (e.g., .env.local).
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const sseData = useSse<SseData | null>(`${API_URL}/api/events/sse`, null);
const [status, setStatus] = useState('');
const sendMessage = async () => {
setStatus('Sending message...');
try {
const response = await fetch(`${API_URL}/api/events/emit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `Hello from client at ${new Date().toLocaleTimeString()}` }),
});
if (response.ok) {
setStatus('Message sent successfully! Waiting for SSE push...');
} else {
const error = await response.text();
setStatus(`Failed to send message: ${error}`);
}
} catch (error) {
setStatus(`Error: ${(error as Error).message}`);
}
};
return (
<div className="mt-8 p-4 border rounded-lg bg-gray-50 w-full">
<h2 className="text-xl font-semibold mb-3">Real-Time Updates (via SSE)</h2>
<div className="mb-4">
<button
onClick={sendMessage}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Trigger Server Push
</button>
{status && <p className="text-sm text-gray-600 mt-2">{status}</p>}
</div>
<div className="p-3 bg-white border rounded shadow-inner min-h-[50px]">
<p className="font-mono text-sm">
<strong>Received Message:</strong>
{sseData ?
<span className="ml-2">{sseData.content} (@ {new Date(sseData.timestamp).toLocaleTimeString()})</span> :
' Waiting for server event...'
}
</p>
</div>
</div>
);
}
Step 2.3: Add the Component to Your Page
Finally, drop the RealTimeUpdates component into whichever page you like. For example, on the home page:
import { RealTimeUpdates } from '@/components/real-time-updates';
export default function HomePage() {
return (
<main className="flex flex-col items-center gap-8 px-4 py-20">
<h1 className="text-3xl font-semibold">Home</h1>
{/* The live panel */}
<RealTimeUpdates />
</main>
);
}
Part 3: Run it and test it
- Run Both Applications: Start your NestJS API and your Next.js web app using their respective
devscripts. - Check the API URL: Ensure the
API_URLinreal-time-updates.tsx(http://localhost:3001by default) correctly points to your running NestJS API. - Open the Home Page: Navigate to your Next.js app in your browser. You should see the “Real-Time Updates” panel.
- Test the Push: Click the “Trigger Server Push” button. You will see the status message change, and a moment later, the “Received Message” text will update with the data pushed from the server.
Part 4: Wiring it into real business logic
The test button is great, but in a real application, you’ll trigger events from your business logic. Since we exported EventsService, you can inject it into any other service.
Example: Pushing a notification when a download finishes.
// In some other service, e.g., downloader.service.ts
import { Injectable } from '@nestjs/common';
import { EventsService } from '../events/events.service';
@Injectable()
export class DownloaderService {
// Inject EventsService into the constructor
constructor(private readonly eventsService: EventsService) {}
async processDownload(url: string) {
// ... logic to download a file ...
console.log('Download complete.');
// Push a notification to the client
const notification = {
message: `Finished downloading from ${url}`,
timestamp: new Date(),
};
this.eventsService.emitEvent({ data: notification });
}
}
You now have a robust and scalable foundation for adding real-time features to your application.
I wrote this while building a download tool whose backend needed to tell the browser when a long-running job finished. SSE turned out to be all it needed: one-way, plain HTTP, automatic reconnection in the browser, and no WebSocket server to run.