Novu is an open-source notification infrastructure platform — new Novu(apiKey) creates the SDK. novu.trigger(workflowId, { to: { subscriberId }, payload }) fires a notification workflow. novu.subscribers.identify(id, { email, firstName, lastName, phone }) creates or updates a subscriber. novu.subscribers.setCredentials(id, "fcm", { deviceTokens: [token] }) adds push notification credentials. The @novu/notification-center React package embeds an inbox UI. Workflows compose steps: email, sms, push, in_app, chat — each configured with a template. digest step batches events over a time window. delay step waits before the next step. Subscriber preferences let users control which notifications they receive. Novu has a cloud offering at novu.co and a self-hosted Docker image. novu.triggerBulk sends to many subscribers. Claude Code generates Novu notification workflows, digest batching, in-app inbox components, and multi-channel delivery.
CLAUDE.md for Novu
## Novu Stack
- Version: @novu/node >= 2.6, @novu/notification-center >= 2.1
- Init: const novu = new Novu(process.env.NOVU_API_KEY!)
- Trigger: await novu.trigger("workflow-id", { to: { subscriberId: userId }, payload: { message, link } })
- Identify: await novu.subscribers.identify(userId, { email, firstName, phone })
- Bulk: await novu.bulkTrigger([{ name: "workflow", to: { subscriberId }, payload }])
- Inbox: <NovuProvider subscriberId={userId} applicationIdentifier={NOVU_APP_ID}><PopoverNotificationCenter /></NovuProvider>
Novu Server Utilities
// lib/notifications/novu.ts — Novu notification utilities
import { Novu } from "@novu/node"
import { db } from "@/lib/db"
import { users } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
export const novu = new Novu(process.env.NOVU_API_KEY!)
// Workflow IDs — defined in Novu dashboard or via code
const WORKFLOWS = {
welcome: "welcome-email",
passwordReset: "password-reset",
orderConfirmed: "order-confirmed",
newComment: "new-comment",
weeklyDigest: "weekly-digest",
mentionAlert: "mention-alert",
} as const
// ── Subscriber management ──────────────────────────────────────────────────
export async function syncSubscriber(userId: string): Promise<void> {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true, name: true, phone: true, avatarUrl: true },
})
if (!user) return
const [firstName, ...rest] = (user.name ?? "").split(" ")
await novu.subscribers.identify(userId, {
email: user.email,
firstName,
lastName: rest.join(" ") || undefined,
phone: user.phone ?? undefined,
avatar: user.avatarUrl ?? undefined,
data: { userId },
})
}
// ── Notification triggers ──────────────────────────────────────────────────
export async function sendWelcomeNotification(userId: string, appName: string): Promise<void> {
await novu.trigger(WORKFLOWS.welcome, {
to: { subscriberId: userId },
payload: {
appName,
loginUrl: `${process.env.APP_URL}/sign-in`,
},
})
}
export async function sendPasswordResetNotification(
userId: string,
resetUrl: string,
expiresInMinutes = 60,
): Promise<void> {
await novu.trigger(WORKFLOWS.passwordReset, {
to: { subscriberId: userId },
payload: {
resetUrl,
expiresInMinutes: String(expiresInMinutes),
},
})
}
export async function sendOrderConfirmation(userId: string, order: {
id: string
total: string
items: Array<{ name: string; quantity: number; price: string }>
}): Promise<void> {
await novu.trigger(WORKFLOWS.orderConfirmed, {
to: { subscriberId: userId },
payload: {
orderId: order.id,
orderTotal: order.total,
orderItems: order.items,
orderUrl: `${process.env.APP_URL}/orders/${order.id}`,
},
})
}
export async function sendMentionAlert(params: {
mentionedUserId: string
mentionedByName: string
contextUrl: string
preview: string
}): Promise<void> {
await novu.trigger(WORKFLOWS.mentionAlert, {
to: { subscriberId: params.mentionedUserId },
payload: {
mentionedByName: params.mentionedByName,
contextUrl: params.contextUrl,
preview: params.preview.slice(0, 200),
},
})
}
// ── Bulk notification ──────────────────────────────────────────────────────
export async function sendWeeklyDigest(userIds: string[]): Promise<void> {
// Novu bulk API sends up to 100 at once
const chunks = []
for (let i = 0; i < userIds.length; i += 100) {
chunks.push(userIds.slice(i, i + 100))
}
for (const chunk of chunks) {
await novu.bulkTrigger(
chunk.map(userId => ({
name: WORKFLOWS.weeklyDigest,
to: { subscriberId: userId },
payload: {
digestUrl: `${process.env.APP_URL}/digest`,
unsubscribeUrl: `${process.env.APP_URL}/unsubscribe/${userId}`,
},
})),
)
}
}
// Register FCM push token for a user
export async function registerPushToken(userId: string, fcmToken: string): Promise<void> {
await novu.subscribers.setCredentials(userId, "fcm", {
deviceTokens: [fcmToken],
})
}
In-App Notification Center
// components/notifications/InAppInbox.tsx — Novu notification center
"use client"
import {
NovuProvider,
PopoverNotificationCenter,
NotificationBell,
useNotifications,
type IMessage,
} from "@novu/notification-center"
const NOVU_APP_ID = process.env.NEXT_PUBLIC_NOVU_APP_ID!
interface InboxProps {
userId: string
subscriberHash?: string // HMAC for production security
}
export function InAppInbox({ userId, subscriberHash }: InboxProps) {
return (
<NovuProvider
subscriberId={userId}
applicationIdentifier={NOVU_APP_ID}
subscriberHash={subscriberHash}
initialFetchingStrategy={{ fetchNotifications: true, fetchUserPreferences: true }}
>
<PopoverNotificationCenter
colorScheme="auto"
onNotificationClick={(notification) => {
const url = notification.cta?.data?.url
if (url) window.location.href = url
}}
onActionButtonClick={(notification, actionButtonType) => {
console.log("Action:", actionButtonType, notification.id)
}}
listItem={(notification, handleActionButtonClick, handleNotificationClick) => (
<CustomNotificationItem
notification={notification}
onClick={handleNotificationClick}
/>
)}
>
{({ unseenCount }) => (
<NotificationBell unseenCount={unseenCount} />
)}
</PopoverNotificationCenter>
</NovuProvider>
)
}
function CustomNotificationItem({
notification,
onClick,
}: {
notification: IMessage
onClick: (n: IMessage) => void
}) {
return (
<div
onClick={() => onClick(notification)}
className={`flex gap-3 p-3 cursor-pointer hover:bg-muted/50 rounded-lg ${!notification.seen ? "bg-blue-50/50" : ""}`}
>
{notification.actor?.avatar && (
<img src={notification.actor.avatar} alt="" className="size-9 rounded-full flex-shrink-0 object-cover" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm line-clamp-2">{notification.content as string}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{new Date(notification.createdAt).toRelativeString?.() ??
new Date(notification.createdAt).toLocaleDateString()}
</p>
</div>
{!notification.seen && (
<div className="size-2 rounded-full bg-blue-500 flex-shrink-0 mt-1.5" />
)}
</div>
)
}
Subscriber Preferences API
// app/api/notifications/preferences/route.ts — manage notification preferences
import { NextRequest, NextResponse } from "next/server"
import { novu } from "@/lib/notifications/novu"
import { auth } from "@clerk/nextjs/server"
// GET preferences
export async function GET(request: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const preferences = await novu.subscribers.getPreference(userId)
return NextResponse.json({ preferences })
}
// PATCH update channel preference
export async function PATCH(request: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { templateId, channel, enabled } = await request.json()
await novu.subscribers.updatePreference(userId, templateId, {
channel: {
type: channel,
enabled,
},
})
return NextResponse.json({ ok: true })
}
For the Knock alternative when a more developer-focused notification platform with per-workflow dashboards, multi-tenant support for SaaS applications, frequency capping, and a richer in-app feed API is preferred — Knock has a more polished dashboard and better multi-tenant isolation, while Novu is fully open-source and self-hostable, see the Knock guide. For the Resend + React Email alternative when only transactional email (no in-app, SMS, or push) is needed without a full notification orchestration platform — Resend with React Email templates is simpler and more cost-effective when you only need email delivery, see the Resend guide. The Claude Skills 360 bundle includes Novu skill sets covering workflows, in-app inbox, and multi-channel delivery. Start with the free tier to try notification infrastructure generation.