Live unread badge
A notification bell whose unread count updates the instant a notification arrives — SSE pushes the change, no polling. Combine the SSE channel with the unread count from the database channel.
A static unread badge goes stale the moment a new notification lands — the user has to refresh to
see it. The fix is two pieces working together: the database channel
owns the count, and the SSE channel pushes a live event the browser
listens to. The badge re-reads the count when the push arrives. No polling, no websockets server to
run — just EventSource.
The recipe: send every in-app notification on both the database and sse channels. The
database row makes it countable and persistent; the SSE event makes the badge update live.
1. Send on both channels
import { Notification } 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 readonly invoice: Invoice) {}
via() {
return [Database, Sse];
}
toDatabase() {
return { invoiceId: this.invoice.id, amount: this.invoice.total };
}
toSse() {
// Whatever the browser needs to react. Keep it small — it rides the live stream.
return { type: 'invoice.paid', invoiceId: this.invoice.id };
}
}Now await notifications.send(user, new InvoicePaid(invoice)) writes a row and pushes an SSE
event to that user's open tabs.
2. Expose the SSE stream and the count
The SSE channel gives you an SseHub to build the endpoint, and the database channel gives you
NotificationsQueryService for the count. Mount both:
import { Controller, Get, Req, Sse as SseEndpoint, type MessageEvent } from '@nestjs/common';
import { SseHub, sseKey } from '@dudousxd/nestjs-notifications-sse';
import { NotificationsQueryService } from '@dudousxd/nestjs-notifications-database';
import type { Observable } from 'rxjs';
@Controller('notifications')
export class NotificationsController {
constructor(
private readonly hub: SseHub,
private readonly notifications: NotificationsQueryService,
) {}
// GET /notifications/unread/count -> { count: number }
@Get('unread/count')
async unreadCount(@Req() req) {
return { count: await this.notifications.unreadCount({ type: 'User', id: req.user.id }) };
}
// GET /notifications/stream -> text/event-stream
@SseEndpoint('stream')
stream(@Req() req): Observable<MessageEvent> {
// Subscribe to the SAME key the sse channel publishes to (tenant-aware).
return this.hub.stream(sseKey(req.tenantId, String(req.user.id)));
}
}hub.stream(key) returns an RxJS Observable of the events pushed to that key — exactly the shape
Nest's @Sse() decorator wants. Build the key with sseKey(tenant, routeValue) so it matches what
the channel published to (the channel uses the notifiable's routeNotificationFor('sse') value).
Each connected tab gets its own subscription, cleaned up automatically when the tab closes.
3. The badge, live — with the React widget
If you use React, the @dudousxd/nestjs-notifications-react package does
the wiring for you. useUnreadCount connects to the stream and re-reads the count on every push:
import { useUnreadCount } from '@dudousxd/nestjs-notifications-react';
export function Badge() {
const { count } = useUnreadCount({
clientOptions: { baseUrl: '/notifications' },
sseUrl: '/notifications/stream',
});
return (
<span className="bell">
🔔{count > 0 && <span className="badge">{count}</span>}
</span>
);
}That's the whole thing — when InvoicePaid is sent, the SSE event arrives, the hook refetches
/notifications/unread/count, and the number ticks up without a refresh. Or drop in the full
<Inbox/> widget, which renders the bell, badge, and dropdown for you.
3b. The badge, live — vanilla JS
No React? EventSource is built into every browser. Listen for the push and refetch the count:
const badge = document.querySelector('#bell-badge')!;
async function refreshCount() {
const res = await fetch('/notifications/unread/count', { credentials: 'include' });
const { count } = await res.json();
badge.textContent = count > 0 ? String(count) : '';
}
const stream = new EventSource('/notifications/stream', { withCredentials: true });
// The sse channel emits a named `notification` event (configurable via the channel's `event`
// option), so listen for that — `onmessage` only fires for unnamed events.
stream.addEventListener('notification', () => refreshCount());
await refreshCount(); // initial paintSend the count as a separate fetch rather than trusting a number embedded in the SSE payload. The
stream tells you something changed; the count endpoint is the source of truth (it also reflects
reads from other tabs). For high-traffic apps you can debounce refreshCount so a burst of events
triggers one fetch.
How it fits together
notifications.send(user, new InvoicePaid())
│
├─ database channel ──> row persisted ──> unreadCount() reads it
│
└─ sse channel ──────> SseHub.push ────> EventSource (browser)
│
badge refetches the countThe two channels are independent — if the user has no tab open, the SSE event is simply dropped and the database row is still there waiting, so the count is correct the next time they load the page. That's the durability of the database channel plus the immediacy of SSE, with no extra moving parts.
Headless SDK & TanStack Query
A framework-neutral client for the inbox API + SSE — fetch functions, a live subscribe(), and TanStack Query option factories that work in React and Vue. For frontends separate from the backend.
Live / progress notifications
Some notifications evolve — an export going 0% → 100%, a job that flips from "running" to "done". Update a single notification row in place across sends with a stable databaseKey, instead of spamming a new row each time.