Aviary

Notifications

Laravel-style notifications for NestJS — define a notification once and deliver it across many channels (mail, database, Slack) and real-time transports (SSE, WebSocket), synchronously or queued.

@dudousxd/nestjs-notifications brings Laravel's notification ergonomics to NestJS. You write a notification once — a plain class that shapes a payload for each channel it cares about — and send it with a single call. That one call fans out to mail, the database, a websocket, Slack, or anything you plug in, synchronously or queued, from a call site that never changes as you add channels.

The core is deliberately thin: it knows only interfaces. Every channel and every async dispatcher is an opt-in package, discovered from the Nest container or selected with a single option — nothing is hard-wired.

The problem it solves

Sending a notification is rarely "send an email." It's an email and a database row for the in-app feed and a realtime push and, when something is on fire, a Slack message — and half of those should run on a queue, not on the request. Wire that by hand and the logic for a single event ends up smeared across a mailer, a repository, a gateway, and a queue processor.

nestjs-notifications collapses that into one class. The recipient decides where a notification can go (@RouteFor), the notification decides what each channel sends (one decorated to<Channel>() method per channel), and the library handles the fan-out, the channel isolation, and — when you ask — the queue. Add a channel by adding a method; the code that calls send() is untouched.

If you've used Laravel notifications, this API will feel immediately familiar. If you haven't, the Quickstart below is the whole loop in three steps.

Quickstart

Install the core and a channel, mark a recipient, write a notification, and send it. For the full walkthrough — adding a second channel, going async — see Getting Started.

Install the core, the mail channel, and the event emitter the core uses for lifecycle events:

pnpm add @dudousxd/nestjs-notifications-core @dudousxd/nestjs-notifications-mail @nestjs/event-emitter

Register the modules in your root module. Channels register themselves and are discovered automatically:

app.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { NotificationsModule } from '@dudousxd/nestjs-notifications-core';
import { MailChannelModule } from '@dudousxd/nestjs-notifications-mail';

@Module({
  imports: [
    EventEmitterModule.forRoot(),
    NotificationsModule.forRoot(),
    MailChannelModule.forRoot({
      from: 'billing@example.com',
      smtp: { host: 'smtp.example.com', port: 587, auth: { user: '…', pass: '…' } },
    }),
  ],
})
export class AppModule {}

Mark a recipient as notifiable. @RouteFor declares the per-channel address; @NotifiableId provides the stable reference used if you ever queue:

user.ts
import { Notifiable, NotifiableId, RouteFor } from '@dudousxd/nestjs-notifications-core';

@Notifiable()
export class User {
  @NotifiableId()
  id: number;

  @RouteFor('mail')
  email: string;

  constructor(id: number, email: string) {
    this.id = id;
    this.email = email;
  }
}

Write the notification. Annotate each payload method with its channel — @Mail() — and the library infers that this notification travels over the mail channel. No via() array, no magic strings:

invoice-paid.notification.ts
import { Notification } from '@dudousxd/nestjs-notifications-core';
import { Mail, MailMessage } from '@dudousxd/nestjs-notifications-mail';

@Notification()
export class InvoicePaid {
  constructor(private invoiceId: string) {}

  @Mail()
  toMail(): MailMessage {
    return new MailMessage()
      .subject(`Invoice ${this.invoiceId} paid`)
      .greeting('Thanks for your payment!')
      .line('We received your payment. No further action is needed.');
  }
}

Inject NotificationService and call send — that's the whole flow:

billing.service.ts
import { Injectable } from '@nestjs/common';
import { NotificationService } from '@dudousxd/nestjs-notifications-core';
import { InvoicePaid } from './invoice-paid.notification';
import { User } from './user';

@Injectable()
export class BillingService {
  constructor(private readonly notifications: NotificationService) {}

  async paid(user: User, invoiceId: string) {
    await this.notifications.send(user, new InvoicePaid(invoiceId));
  }
}

To add an in-app feed entry, install the database channel and add a second decorated method (@Database()) — the call site above does not change. The notification simply fans out to both channels.

Two orthogonal abstractions

The library keeps two concerns strictly separate, and that separation is the key to its flexibility:

  • Channel drivers decide how a notification leaves — mail, database, broadcast, Slack, and more. Each is an opt-in package; a notification opts into a channel by adding one decorated payload method.
  • Dispatch drivers decide where and when it is processed — inline and synchronous by default, or in-process events, Redis, or the BullMQ you already run.

Because they're independent, any combination works: you can send by mail asynchronously through BullMQ, or to the database synchronously, without either choice leaking into the other. Going async is a shouldQueue flag on the notification plus a dispatcher option — the call site stays the same.

Real-time & in-app notifications

Notifications aren't only email and Slack — a lot of them belong in your app, live: a bell that lights up, a feed that updates without a refresh, an unread badge that ticks the instant something happens. This library is that layer too. Two of its channels push server → browser in real time:

  • SSE (@dudousxd/nestjs-notifications-sse) — native NestJS Server-Sent Events. No socket server; EventSource auto-reconnects; a Redis backplane fans out across instances; the React package consumes it directly. The default for in-app notifications.
  • WebSocket (@dudousxd/nestjs-notifications-broadcast) — socket.io, for when you want a bidirectional channel or already run one.

Pair either with the database channel (persist for the inbox and unread count) and the React inbox (<Inbox/> + hooks) and you have the full loop — one send() writes the row and pushes it live. The Real-time & in-app guide walks the whole thing end to end and helps you pick SSE vs WebSocket.

Type-safe and observable

Each channel exports an interface its payload method returns, so toMail, toDatabase, and toSlack are checked at compile time — a malformed payload fails the build, not production. Every send also emits lifecycle events, and a first-class nestjs-telescope watcher records each delivery: channel, recipient, notification class, payload, and any failure reason.

For tests, a NotificationFake swaps the real service in and exposes Laravel-style assertions, so you can verify what would have been sent without touching real SMTP, sockets, or queues.

Where to go next

On this page