Novu: Notification Engine That Actually Handles Email + SMS + Push Together

I built a notification system from scratch three times. The first time was just SendGrid for emails. Then I added Twilio for SMS. Then Firebase for push notifications. Three different APIs, three different dashboard, three different sets of templates. When a user said "I only want push, not email," I had to code that logic myself. Novu replaces all of that with a single workflow engine. One API call triggers email + SMS + push through a template system with user preference management. Here's what I learned.

Why Not SendGrid + Twilio Separately

The problem with separate providers:

Novu solves all of this by abstracting the providers behind a workflow engine. You define workflows (when to send, what channels), templates (what the message looks like), and user preferences (what each user wants). Novu handles the rest.

Building a Notification Workflow

# Install Novu
npm install @novu/node @novu/api

# Start Novu locally (self-hosted)
npx novu start
# Dashboard: http://localhost:4200
// Create a notification workflow programmatically
import { Novu } from "@novu/node"

const novu = new Novu(process.env.NOVU_API_KEY!)

// Define a workflow: "order-shipped"
// This sends email + SMS + push when an order ships
await novu.workflows.create({
  name: "order-shipped",
  notificationGroupId: "orders",
  steps: [
    // Step 1: Send email immediately
    {
      type: "email",
      name: "shipping-email",
      template: {
        subject: "Your order {{orderNumber}} has shipped!",
        body: `Hi {{firstName}},

Your order #{{orderNumber}} is on its way!

Tracking: {{trackingUrl}}
Expected delivery: {{estimatedDelivery}}

Thanks for shopping with us.`,
      },
    },
    // Step 2: Send SMS (only if email bounces)
    {
      type: "sms",
      name: "shipping-sms",
      template: {
        content: "Your order #{{orderNumber}} has shipped! Track: {{trackingUrl}}",
      },
    },
    // Step 3: Send push notification
    {
      type: "push",
      name: "shipping-push",
      template: {
        title: "Order Shipped!",
        content: "Order #{{orderNumber}} is on its way",
      },
    },
  ],
})

// Trigger the workflow
await novu.trigger("order-shipped", {
  to: {
    subscriberId: "user-123",
    email: "alice@example.com",
    phone: "+1234567890",
  },
  payload: {
    firstName: "Alice",
    orderNumber: "ORD-789",
    trackingUrl: "https://track.example.com/ORD-789",
    estimatedDelivery: "April 15, 2026",
  },
})

One API call. Novu sends the email, the SMS, and the push notification. It respects user preferences (if Alice disabled push, she won't get it). It handles retries and delivery tracking.

Problem

Email templates with Handlebars would break when the payload contained HTML characters (&, <, >) or when nested objects were passed. User names like "O'Brien" caused template rendering to fail silently — the notification was marked as "sent" but the email content was corrupted.

What I Tried

Tried escaping all payload values before passing to Novu. That worked for HTML but broke URLs and phone numbers. Tried using triple braces {{{variable}}} in templates — introduced XSS risk.

Actual Fix

Use double braces for auto-escaped content and sanitize payload data at the application level:

// Sanitize payload before triggering
function sanitizePayload(payload: Record) {
  const sanitized: Record = {}
  for (const [key, value] of Object.entries(payload)) {
    if (typeof value === "string") {
      // Only escape for email templates, not for SMS/push
      sanitized[key] = value.replace(/&/g, "&")
                            .replace(//g, ">")
    } else {
      sanitized[key] = value
    }
  }
  return sanitized
}

// Or better: use separate templates for email (HTML) vs SMS (plain text)
// Novu supports different content per channel in the same workflow

Template Engine with Handlebars

Novu's template engine supports Handlebars with custom helpers. Here's a more complex template for a weekly digest:

// Weekly activity digest template
const digestTemplate = `

Hi {{firstName}}, here's your weekly summary

{{#if hasNewFollowers}}

New Followers ({{newFollowerCount}})

    {{#each newFollowers}}
  • {{this.name}} — View Profile
  • {{/each}}
{{/if}} {{#if hasNewComments}}

Comments on Your Posts ({{commentCount}})

{{#each comments}}

{{this.author}} commented on "{{this.postTitle}}":

{{this.content}}

{{/each}} {{/if}} {{#unless hasActivity}}

Nothing new this week. Check out what's trending.

{{/unless}} ` // Trigger with array data await novu.trigger("weekly-digest", { to: { subscriberId: userId, email: userEmail }, payload: { firstName: user.name, hasNewFollowers: newFollowers.length > 0, newFollowerCount: newFollowers.length, newFollowers: newFollowers.map(f => ({ name: f.name, id: f.id })), hasNewComments: comments.length > 0, commentCount: comments.length, comments: comments, hasActivity: newFollowers.length > 0 || comments.length > 0, }, })

Digest and Aggregation for Spam Prevention

The most underrated Novu feature. Instead of sending 20 emails for 20 comments, digest them into one:

// Define a digest workflow
await novu.workflows.create({
  name: "comment-notification",
  steps: [
    // Step 1: Digest — collect comments for 1 hour
    {
      type: "digest",
      name: "comment-digest",
      digestMeta: {
        amount: 1,
        unit: "hours", // Batch all comments within 1 hour
      },
    },
    // Step 2: Send aggregated email
    {
      type: "email",
      name: "comment-email",
      template: {
        subject: "You have {{events.length}} new comments",
        body: `Hi {{firstName}},

You received {{events.length}} new comments:

{{#each events}}
- {{this.payload.authorName}}: "{{this.payload.content}}"
{{/each}}

View all: https://app.example.com/comments`,
      },
    },
  ],
})

// Trigger for each comment — Novu batches them
for (const comment of comments) {
  await novu.trigger("comment-notification", {
    to: { subscriberId: postAuthorId, email: postAuthorEmail },
    payload: {
      firstName: postAuthorName,
      authorName: comment.author,
      content: comment.text,
    },
  })
}
// Result: one email with all comments, sent 1 hour after the first comment

Problem

When 5 users commented within seconds, the digest sent 3 emails instead of 1. The events weren't being grouped into the same digest window because they triggered before the first digest timer was established.

Actual Fix

Add a digestKey to group related events. Events with the same key go into the same digest window:

// Use digestKey to group events for the same user/post
await novu.trigger("comment-notification", {
  to: { subscriberId: postAuthorId },
  payload: { /* ... */ },
  // All comments on the same post go into one digest
  overrides: {
    "comment-digest": {
      digestKey: `post-${postId}`, // Group key
    },
  },
})

User Preference Management

Novu stores per-user notification preferences. Users can opt out of specific channels or specific notification types:

// Set user preferences via API
await novu.subscribers.updatePreferences("user-123", {
  // Global channel preferences
  channels: {
    email: true,
    sms: false,    // No SMS for this user
    push: true,
  },
  // Per-workflow preferences
  workflows: {
    "order-shipped": {
      enabled: true,
      channels: { email: true, sms: false, push: true },
    },
    "marketing-update": {
      enabled: false, // Completely opted out of marketing
    },
    "weekly-digest": {
      enabled: true,
      channels: { email: true },
    },
  },
})

// When you trigger a notification, Novu checks preferences automatically
// If the user disabled marketing notifications, this trigger is silently skipped
await novu.trigger("marketing-update", {
  to: { subscriberId: "user-123" },
  payload: { /* ... */ },
})

// Frontend: let users manage their own preferences
import { NovuProvider, Preferences } from "@novu/react"

function NotificationSettings() {
  return (
    <NovuProvider subscriberId="user-123" applicationIdentifier={APP_ID}>
      <Preferences /> {/* Built-in preferences UI */}
    </NovuProvider>
  )
}

What I Learned

Wrapping Up

Novu replaced three separate notification services (SendGrid, Twilio, Firebase) with one workflow engine. The digest feature alone was worth the migration — my users went from getting 20 emails per day to getting one well-formatted digest. And the built-in preference management means I don't have to build notification settings into every app. If you're sending notifications through more than one channel, Novu is the right tool.

Related Articles