Real-time & in-app
nestjs-notifications is also your real-time delivery layer. Persist with the database channel, push live with SSE or WebSocket, and consume in React — the whole in-app notification loop, and how to pick SSE vs WebSocket.
If you're reaching for SSE or a WebSocket to power in-app notifications — a bell that lights up, a
feed that updates without a refresh, a live unread badge — this library is that layer. You don't
add a separate realtime stack: a notification fans out to a channel that persists it and a channel
that pushes it, from the same send() call.
The shape: persist + push + consume
An in-app notification is three jobs, and each is a package you already have:
| Job | Use | Why |
|---|---|---|
| Persist (history, unread count, "mark read") | database channel | The inbox is a table. The realtime push is fire-and-forget — without persistence a notification sent while the user is offline is gone. |
| Push (deliver live, now) | sse or broadcast channel | Server → browser the instant it happens, no polling. |
| Consume (render it) | @dudousxd/nestjs-notifications-react | A bell, badge, dropdown, and hooks that read the REST API and live stream. |
One notification opts into all three by adding one decorated method each — the call site never changes:
import { Notification, type Notifiable } from '@dudousxd/nestjs-notifications-core';
import { Database } from '@dudousxd/nestjs-notifications-database';
import { Sse } from '@dudousxd/nestjs-notifications-sse';
@Notification()
export class InvoicePaid {
constructor(private invoiceId: string) {}
@Database() // persisted for the inbox
toDatabase() {
return { type: 'invoice.paid', invoiceId: this.invoiceId };
}
@Sse() // pushed live to the open stream
toSse({ notifiable }: ChannelContext): Record<string, unknown> {
return { type: 'invoice.paid', invoiceId: this.invoiceId };
}
}await notifications.send(user, new InvoicePaid(id)) now writes a row and pushes to the user's
live stream in one shot.
SSE or WebSocket — which channel?
Both push server → browser in real time. Pick by how you want the transport to work:
sse — Server-Sent Events | broadcast — WebSocket | |
|---|---|---|
| Package | @dudousxd/nestjs-notifications-sse | @dudousxd/nestjs-notifications-broadcast |
| Transport | Native NestJS @Sse() over plain HTTP | socket.io (@nestjs/websockets) |
| Direction | One-way (server → client) | Bidirectional |
| Infra | None — it's just an HTTP response; EventSource auto-reconnects | A socket.io server + namespace |
| Multi-instance | Redis backplane built in | socket.io adapter (e.g. Redis) |
| React hooks consume it | ✅ directly (sseUrl) | via your own client |
Default to SSE. Notifications are one-way (server → client), and SSE needs no socket server,
reconnects on its own, and the React package consumes it out of the box. Reach for broadcast
when you're already running socket.io or genuinely need a two-way channel.
End to end in three steps
Each step links to its full recipe — this page is the map that connects them.
1. Persist — register the database channel. It auto-mounts the inbox REST API
(GET/POST/DELETE /notifications) and provides NotificationsQueryService (all, unreadCount,
markAllAsRead, …). See In-app notifications.
DatabaseChannelModule.forRoot(); // controller on by default, scoped to req.user2. Push — register SSE and mount the stream. The channel publishes into an SseHub; you expose
the endpoint with Nest's own @Sse(), keyed per user (and tenant). See the
SSE channel.
@Sse('stream')
stream(@Req() req: any): Observable<MessageEvent> {
return this.hub.stream(sseKey(req.tenantId, String(req.user.id)));
}3. Consume — drop in the React inbox. Point it at the REST base and the stream; the badge updates live off SSE. See the React inbox.
<Inbox clientOptions={{ baseUrl: '/notifications' }} sseUrl="/notifications/stream" />That's the entire loop: send() persists + pushes, the browser is already listening, the badge ticks
up — no polling, no separate realtime service.
Cross-device read sync (optional)
The live channels push new notifications. Read-sync pushes the inverse — when a user marks one read on their phone, the unread badge on their laptop clears too, without a refetch. It rides the same SSE stream.
Bind the SSE publisher under the database channel's READ_SYNC_PUBLISHER token; with it bound,
marking read publishes a read event to the user's other open streams:
import { READ_SYNC_PUBLISHER } from '@dudousxd/nestjs-notifications-database';
import { SseReadSyncPublisher } from '@dudousxd/nestjs-notifications-sse';
@Module({
providers: [{ provide: READ_SYNC_PUBLISHER, useClass: SseReadSyncPublisher }],
})
export class AppModule {}The auto-mounted inbox controller already passes the current notifiable to markAsRead(id, target) /
markAllAsRead(target), so the read event is scoped to that user (and tenant). Absent the binding,
read-sync is simply a no-op.
On the client, feed the stream's onRead into the inbox so other tabs patch in place:
const { applyReadEvent } = useNotifications();
useNotificationsStream({ url: '/notifications/stream', onRead: applyReadEvent });ReadSyncEvent is { notificationId: string | null; readAt: string } — notificationId: null means
"mark all read". (<Inbox/> wires this for you.)
Going deeper
- Live unread badge — the bell count updating the instant a notification lands, via SSE.
- Progress / live notifications — update one notification in
place (0% → 100%) across sends with a stable
databaseKey, instead of spamming rows. - React inbox widget —
<Inbox/>,NotificationsProvider, and theuseNotifications/useUnreadCounthooks.
The live channels are fire-and-forget: publishing to a user with no open stream is a no-op. Always pair SSE/broadcast with the database channel so a notification sent while the user is away is still waiting in the inbox when they return.
Queued notifications
Move delivery off the request, end to end — set shouldQueue, register the notification and resolveNotifiable, and pick a dispatcher. The call site never changes.
In-app notifications
Build an in-app feed from persisted notifications. Inject NotificationsQueryService to list, count, and mark read — or mount the optional REST controller in one call.