How to Send Notifications in NestJS (Email, In-App, and Multi-Channel)
A complete walkthrough of building a notifications system in NestJS — store in-app notifications in the database, send transactional emails with nodemailer, and dispatch the same notification across multiple channels at once.
Khatab Wedaa
Software Engineer · Nestbolt
Every serious application eventually grows a notifications layer. Welcome emails when users sign up, "your order shipped" alerts, password reset links, in-app bell icons with unread counts, and — eventually — push notifications and Slack webhooks for the ops team. The mistake teams make is solving each one in isolation: a MailerService here, a notifications table there, a feature-flagged PushService over there. Six months later, sending the same notification across two channels means writing it twice.
This guide walks through a single notifications abstraction in NestJS that handles in-app database notifications and transactional email through one API, and shows how to extend it to any new channel without touching the core. We'll use TypeORM for persistence, nodemailer for email, and a small base class to tie everything together. All code is TypeScript and works on NestJS 10+.
What you'll build
By the end of this tutorial, your NestJS app will support:
- A
notificationstable with unread tracking per entity (users, teams, anything) - A
Notificationbase class your domain code subclasses (WelcomeNotification,OrderShippedNotification, etc.) - A
NotificationService.send(user, new WelcomeNotification(user))API - Per-channel rendering — the same notification class generates a
MailMessagefor email and a JSON payload for the database - A pluggable channel system you can extend with Slack, SMS, or push without changing the core
Prerequisites
- Node.js 18 or later
- A NestJS 10+ project with TypeORM configured
- An SMTP endpoint for email (Mailtrap, SES, or Postmark all work; Gmail's SMTP works for testing too)
1. Install dependencies
npm install nodemailer
npm install --save-dev @types/nodemailerThat's it for the core. We'll add @nestjs/event-emitter later for lifecycle hooks.
2. Define the notifiable contract
A "notifiable" is anything that can receive a notification. In most apps that means User, but it could also be Team, Organization, or even WebhookSubscription. The contract is small:
// src/notifications/notifiable.interface.ts
export interface Notifiable {
id: string;
routeNotificationFor(channel: string): string | undefined;
}routeNotificationFor returns the address for a given channel — an email for "mail", a Slack webhook URL for "slack", etc. This keeps channel-specific addresses on the entity where they belong.
// src/users/user.entity.ts
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("users")
export class User extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column() name!: string;
@Column() email!: string;
@Column({ nullable: true }) slackWebhookUrl?: string;
routeNotificationFor(channel: string): string | undefined {
if (channel === "mail") return this.email;
if (channel === "slack") return this.slackWebhookUrl;
return undefined;
}
}3. The Notification base class
This is the core abstraction. Every notification subclass declares which channels it uses and provides a render method per channel:
// src/notifications/notification.ts
export abstract class Notification {
abstract via(): string[];
abstract notificationType(): string;
}Why is it abstract? Because a notification is just a typed payload — the channel renderers are what turn it into an email or a database row. Subclasses opt into channels by implementing the matching method (toDatabase(), toMail(), etc.), and via() declares which ones to fire.
A concrete example:
// src/notifications/welcome.notification.ts
import { Notification } from "./notification";
export class WelcomeNotification extends Notification {
constructor(private user: { name: string }) {
super();
}
via(): string[] {
return ["database", "mail"];
}
toDatabase() {
return {
message: `Welcome to the platform, ${this.user.name}!`,
type: "welcome",
};
}
toMail() {
return new MailMessage()
.subject("Welcome to Nestbolt")
.greeting(`Hello ${this.user.name}`)
.line("Thanks for signing up.")
.action("Open Dashboard", "https://app.example.com")
.line("If you have any questions, just reply to this email.");
}
notificationType(): string {
return "WelcomeNotification";
}
}The same class drives both channels — change the welcome copy in one place and email + in-app stay in sync.
4. The database channel
Persisting notifications enables the bell icon, unread badges, and notification feeds. The schema is small:
// src/notifications/notification.entity.ts
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("notifications")
@Index(["notifiableType", "notifiableId"])
@Index(["notifiableType", "notifiableId", "readAt"])
export class NotificationEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column() type!: string;
@Column({ name: "notifiable_type" })
notifiableType!: string;
@Column({ name: "notifiable_id" })
notifiableId!: string;
@Column() channel!: string;
@Column({ type: "simple-json" })
data!: Record<string, unknown>;
@Column({ name: "read_at", type: "datetime", nullable: true })
readAt?: Date | null;
@CreateDateColumn({ name: "created_at" })
createdAt!: Date;
@UpdateDateColumn({ name: "updated_at" })
updatedAt!: Date;
}The notifiableType + notifiableId polymorphic pair is what lets one table serve User, Team, Organization, etc. The composite index on (notifiableType, notifiableId, readAt) keeps unread counts fast even at millions of rows.
Now the channel itself:
// src/notifications/channels/database.channel.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Notification } from "../notification";
import { Notifiable } from "../notifiable.interface";
import { NotificationEntity } from "../notification.entity";
@Injectable()
export class DatabaseChannel {
constructor(
@InjectRepository(NotificationEntity)
private readonly repo: Repository<NotificationEntity>,
) {}
async send(notifiable: Notifiable, notification: Notification & {
toDatabase: () => Record<string, unknown>;
}): Promise<void> {
await this.repo.save(
this.repo.create({
type: notification.notificationType(),
notifiableType: notifiable.constructor.name,
notifiableId: notifiable.id,
channel: "database",
data: notification.toDatabase(),
}),
);
}
}5. The mail channel
The trick to clean transactional email is a fluent builder that produces both HTML and plain-text variants. Most spam filters ding emails that ship HTML-only:
// src/notifications/mail-message.ts
export class MailMessage {
private _subject = "";
private _greeting?: string;
private _lines: string[] = [];
private _action?: { text: string; url: string };
private _salutation?: string;
subject(value: string) { this._subject = value; return this; }
greeting(value: string) { this._greeting = value; return this; }
line(value: string) { this._lines.push(value); return this; }
action(text: string, url: string) { this._action = { text, url }; return this; }
salutation(value: string) { this._salutation = value; return this; }
toHtml(): string {
const lines = this._lines.map((l) => `<p>${escape(l)}</p>`).join("");
const cta = this._action
? `<p><a href="${this._action.url}" style="background:#ea2845;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">${escape(this._action.text)}</a></p>`
: "";
return `<h1>${escape(this._greeting ?? "")}</h1>${lines}${cta}<p>${escape(this._salutation ?? "Thanks")}</p>`;
}
toText(): string {
const lines = this._lines.join("\n\n");
const cta = this._action ? `\n\n${this._action.text}: ${this._action.url}` : "";
return `${this._greeting ?? ""}\n\n${lines}${cta}\n\n${this._salutation ?? "Thanks"}`;
}
get headers() { return { subject: this._subject }; }
}
function escape(s: string) {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
);
}Wire that into a nodemailer-backed channel:
// src/notifications/channels/mail.channel.ts
import { Inject, Injectable } from "@nestjs/common";
import nodemailer, { Transporter } from "nodemailer";
import { Notification } from "../notification";
import { Notifiable } from "../notifiable.interface";
import { MailMessage } from "../mail-message";
@Injectable()
export class MailChannel {
private readonly transporter: Transporter;
constructor(@Inject("MAIL_CONFIG") cfg: {
transport: nodemailer.TransportOptions;
from: string;
}) {
this.transporter = nodemailer.createTransport(cfg.transport);
this.from = cfg.from;
}
private readonly from: string;
async send(notifiable: Notifiable, notification: Notification & {
toMail: () => MailMessage;
}): Promise<void> {
const to = notifiable.routeNotificationFor("mail");
if (!to) throw new Error("No mail address for notifiable");
const message = notification.toMail();
await this.transporter.sendMail({
from: this.from,
to,
subject: message.headers.subject,
html: message.toHtml(),
text: message.toText(),
});
}
}Always send text alongside html. Spam scores improve, and email clients without HTML rendering (Apple Watch, terminal mail clients) still see something readable.
6. The dispatcher
Now glue it together. NotificationService.send() reads via(), finds the matching channel, and dispatches:
// src/notifications/notification.service.ts
import { Injectable } from "@nestjs/common";
import { DatabaseChannel } from "./channels/database.channel";
import { MailChannel } from "./channels/mail.channel";
import { Notifiable } from "./notifiable.interface";
import { Notification } from "./notification";
@Injectable()
export class NotificationService {
constructor(
private readonly database: DatabaseChannel,
private readonly mail: MailChannel,
) {}
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
const channels = notification.via();
await Promise.all(
channels.map(async (channel) => {
try {
if (channel === "database") {
await this.database.send(notifiable, notification as never);
} else if (channel === "mail") {
await this.mail.send(notifiable, notification as never);
} else {
throw new Error(`Unknown channel: ${channel}`);
}
} catch (error) {
// log + continue — one channel failing shouldn't block the others
console.error(`Notification channel "${channel}" failed`, error);
}
}),
);
}
}Promise.all runs channels in parallel and the per-channel try/catch guarantees a Mailgun outage doesn't take out your in-app notifications. In production you'd swap console.error for a structured logger and probably retry email through a queue.
Use it from anywhere:
@Injectable()
export class UserService {
constructor(private readonly notifications: NotificationService) {}
async register(name: string, email: string): Promise<User> {
const user = await User.save({ name, email } as User);
await this.notifications.send(user, new WelcomeNotification(user));
return user;
}
}7. Querying notifications for the bell icon
Every app with in-app notifications needs the same three queries — list, unread count, mark as read. Build them once in a service:
async getNotifications(notifiable: Notifiable, limit = 20) {
return this.repo.find({
where: {
notifiableType: notifiable.constructor.name,
notifiableId: notifiable.id,
channel: "database",
},
order: { createdAt: "DESC" },
take: limit,
});
}
async getUnreadCount(notifiable: Notifiable): Promise<number> {
return this.repo.count({
where: {
notifiableType: notifiable.constructor.name,
notifiableId: notifiable.id,
channel: "database",
readAt: IsNull(),
},
});
}
async markAsRead(notifiable: Notifiable, notificationId: string) {
await this.repo.update(
{
id: notificationId,
notifiableType: notifiable.constructor.name,
notifiableId: notifiable.id,
},
{ readAt: new Date() },
);
}Expose those through a /notifications controller and your frontend has everything it needs for the bell icon.
8. Adding a new channel
The whole point of this design is that a new channel — Slack, SMS, push — costs almost nothing. Implement the channel:
@Injectable()
export class SlackChannel {
async send(notifiable: Notifiable, notification: Notification & {
toSlack: () => { text: string };
}): Promise<void> {
const url = notifiable.routeNotificationFor("slack");
if (!url) throw new Error("No slack webhook for notifiable");
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(notification.toSlack()),
});
}
}Register it in the dispatcher's switch statement, then any notification class can opt in:
class CriticalAlertNotification extends Notification {
via() { return ["database", "mail", "slack"]; }
toDatabase() { /* ... */ }
toMail() { /* ... */ }
toSlack() { return { text: ":fire: Critical alert: ..." }; }
}The notification class doesn't know how channels are dispatched — and the channels don't know what notifications exist. That's the abstraction earning its keep.
Common pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Email sends but goes to spam | HTML-only message, missing SPF/DKIM | Send text + html, configure SPF/DKIM/DMARC for your sending domain |
| Unread count query is slow | No composite index | Add (notifiable_type, notifiable_id, read_at) index |
| One channel failure kills the rest | Sequential await without try/catch | Use Promise.all + per-channel try/catch |
| Notifications fire twice | Service called inside a transaction that retries | Move notify() out of the transaction or use afterCommit hooks |
| Email blocks the request | Synchronous send in HTTP handler | Push to a queue (BullMQ, SQS) and process out-of-band |
Going further: @nestbolt/notifications
The system above covers the structure but glosses over what production needs: lifecycle events (notification.sent, notification.failed, notification.read) for auditing, queueable channels, retries, batch sends, custom notification types per entity, and a clean user.notify(...) mixin so callers don't need to inject a service.
@nestbolt/notifications is exactly that as a NestJS module. Decorate an entity with @Notifiable(), extend NotifiableMixin(BaseEntity), and you get notify(), getNotifications(), unreadNotifications(), markNotificationAsRead(), and the unread count helper directly on the entity. Database and mail channels ship out of the box; custom channels plug in through a single interface.
@Entity("users")
@Notifiable()
export class User extends NotifiableMixin(BaseEntity) {
@PrimaryGeneratedColumn("uuid") id!: string;
@Column() email!: string;
routeNotificationFor(channel: string) {
if (channel === "mail") return this.email;
}
}
// Anywhere in your app:
await user.notify(new OrderShippedNotification(order));
const unread = await user.getUnreadNotificationCount();Read the Quick Start for the full setup, Mail Channel for the MailMessage builder API, or Custom Channels for adding Slack, SMS, or push.
If you found this useful, star the repo on GitHub — it's how more developers find the package.
Written by Khatab Wedaa
Software Engineer · Nestbolt
Building open-source NestJS packages — authentication, permissions, audit logs, media uploads, and the patterns every backend ends up rebuilding.