How to Build a Notification System (2026)
Every app needs notifications — email, push, in-app, SMS. Building a good notification system is harder than it looks. Here's how to do it right.
The Challenge
Notifications seem simple but involve:
- Multiple channels (email, push, in-app, SMS, Slack)
- User preferences (don't spam people)
- Templates and personalization
- Delivery tracking
- Rate limiting and batching
- Timezone handling
Option 1: Novu (Recommended for Most)
Novu is an open-source notification infrastructure platform:
npm install @novu/node
import { Novu } from '@novu/node'
const novu = new Novu(process.env.NOVU_API_KEY)
// Send a notification across channels
await novu.trigger('welcome-flow', {
to: { subscriberId: 'user-123', email: 'user@example.com' },
payload: { name: 'John', planName: 'Pro' },
})
What Novu handles:
- Multi-channel delivery (email, push, in-app, SMS, chat)
- User preference management
- Template management with variables
- Delivery tracking and analytics
- Digest/batching (combine 10 notifications into 1)
Pricing: Free (30K events/month), Pro from $250/month.
Option 2: Build It Yourself
For simpler needs, build a lightweight system:
Database Schema
// Drizzle schema
export const notifications = pgTable('notifications', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
type: text('type').notNull(), // 'comment', 'mention', 'system'
title: text('title').notNull(),
body: text('body').notNull(),
read: boolean('read').default(false),
readAt: timestamp('read_at'),
channel: text('channel').notNull(), // 'in_app', 'email', 'push'
metadata: jsonb('metadata'), // Additional data
createdAt: timestamp('created_at').defaultNow(),
})
export const notificationPreferences = pgTable('notification_preferences', {
userId: text('user_id').primaryKey(),
emailEnabled: boolean('email_enabled').default(true),
pushEnabled: boolean('push_enabled').default(true),
digestEnabled: boolean('digest_enabled').default(false),
quietHoursStart: text('quiet_hours_start'), // '22:00'
quietHoursEnd: text('quiet_hours_end'), // '08:00'
timezone: text('timezone').default('UTC'),
})
Notification Service
class NotificationService {
async send(userId: string, notification: NotificationPayload) {
const prefs = await this.getPreferences(userId)
// In-app notification (always)
await this.createInAppNotification(userId, notification)
// Email (if enabled and not in quiet hours)
if (prefs.emailEnabled && !this.isQuietHours(prefs)) {
await emailQueue.add('send', {
to: await this.getUserEmail(userId),
subject: notification.title,
body: notification.body,
})
}
// Push (if enabled)
if (prefs.pushEnabled) {
await pushQueue.add('send', {
userId,
title: notification.title,
body: notification.body,
})
}
}
private isQuietHours(prefs: UserPreferences): boolean {
if (!prefs.quietHoursStart) return false
const now = new Date().toLocaleTimeString('en-US', {
hour12: false,
timeZone: prefs.timezone,
})
return now >= prefs.quietHoursStart || now <= prefs.quietHoursEnd
}
}
In-App Notification Bell
'use client'
import { useState, useEffect } from 'react'
function NotificationBell() {
const [notifications, setNotifications] = useState([])
const [unreadCount, setUnreadCount] = useState(0)
useEffect(() => {
fetch('/api/notifications?unread=true')
.then(r => r.json())
.then(data => {
setNotifications(data.notifications)
setUnreadCount(data.unreadCount)
})
}, [])
const markAsRead = async (id: string) => {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' })
setUnreadCount(prev => prev - 1)
}
return (
<div className="relative">
<button>
🔔 {unreadCount > 0 && <span className="badge">{unreadCount}</span>}
</button>
<div className="dropdown">
{notifications.map(n => (
<div key={n.id} onClick={() => markAsRead(n.id)}>
<strong>{n.title}</strong>
<p>{n.body}</p>
</div>
))}
</div>
</div>
)
}
API Routes
// GET /api/notifications
export async function GET(req: Request) {
const user = await auth()
const url = new URL(req.url)
const unreadOnly = url.searchParams.get('unread') === 'true'
const query = db.select().from(notifications)
.where(eq(notifications.userId, user.id))
.orderBy(desc(notifications.createdAt))
.limit(50)
if (unreadOnly) query.where(eq(notifications.read, false))
return Response.json({ notifications: await query })
}
// POST /api/notifications/:id/read
export async function POST(req: Request, { params }) {
await db.update(notifications)
.set({ read: true, readAt: new Date() })
.where(eq(notifications.id, params.id))
return Response.json({ success: true })
}
Notification Best Practices
- Respect user preferences — let users control what they receive
- Batch/digest — don't send 50 emails for 50 comments. Batch into a daily digest.
- Quiet hours — respect timezones and sleep
- Progressive channels — in-app first, email for important, SMS for critical only
- Unsubscribe — always include unsubscribe links in emails
- Rate limit — cap notifications per user per hour
Decision: Build vs Buy
< 5 notification types, 1-2 channels → Build it yourself
Multiple channels, complex routing → Novu (free tier)
Enterprise, compliance needs → Knock or Courier
FAQ
Should I use Novu or build custom?
If you need more than email + in-app, use Novu. The multi-channel routing, preference management, and digest features are hard to build well.
How do I handle push notifications?
Use Firebase Cloud Messaging (FCM) for web push and mobile. Novu integrates with FCM directly.
What about real-time in-app notifications?
Use Server-Sent Events (SSE) or WebSockets to push notifications to connected clients. Supabase Realtime works well for this.
Bottom Line
Start with a simple in-app notification system (database + API + bell component). Add email via Resend or Loops. When you need multi-channel routing, user preferences, and digests — adopt Novu (free up to 30K events/month). Don't over-engineer notifications early.