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:
- Three different template systems. SendGrid uses Handlebars, Twilio uses their own format, Firebase has no templates.
- No unified user preferences. "Don't email me about X but SMS me about Y" requires custom code per provider.
- No digest/aggregation. If a user gets 20 notifications in an hour, you want to batch them into one email. You have to build that yourself.
- No single dashboard. You can't see all notification activity in one place.
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
- Use Novu from the start. Retrofitting a unified notification system onto separate SendGrid/Twilio integrations is painful. Start with Novu.
- Digest is the killer feature. Batching notifications into hourly or daily digests prevents notification fatigue and reduces email costs.
- Let users control their preferences. Novu's built-in preference UI saves you from building a settings page.
- Template testing matters. Test templates with real data (including special characters) before going live. Use the Novu dashboard's preview feature.
- Self-hosted Novu costs nothing at scale. Cloud plan charges per notification. Self-hosted is free — just pay for your email/SMS providers.
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.