← Back to articles

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

  1. Respect user preferences — let users control what they receive
  2. Batch/digest — don't send 50 emails for 50 comments. Batch into a daily digest.
  3. Quiet hours — respect timezones and sleep
  4. Progressive channels — in-app first, email for important, SMS for critical only
  5. Unsubscribe — always include unsubscribe links in emails
  6. 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.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.