Skip to main content

Guide

Novu vs Knock vs Courier (2026)

Compare Novu, Knock, and Courier for notification infrastructure in JavaScript applications. Multi-channel delivery, templates, preferences, digests, and.

·PkgPulse Team·
0

TL;DR

Novu is the open-source notification infrastructure — multi-channel delivery (email, SMS, push, in-app, chat), workflow engine, subscriber preferences, digest/batch, self-hostable. Knock is the developer-friendly notification API — workflows, preferences, in-app feeds, batching, real-time, designed for fast integration. Courier is the notification orchestration platform — designer-friendly template builder, routing, preferences, multi-channel, strong enterprise features. In 2026: Novu for open-source notification infrastructure, Knock for the best developer experience, Courier for visual template design and enterprise.

Key Takeaways

  • Novu: @novu/node ~80K weekly downloads — open-source, self-hostable, workflow engine
  • Knock: @knocklabs/node ~30K weekly downloads — developer-first, real-time feeds, batching
  • Courier: @trycourier/courier ~20K weekly downloads — visual designer, routing, enterprise
  • Novu is open-source and can be self-hosted
  • Knock has the best in-app notification feed component
  • Courier has the most powerful visual template designer

Novu

Novu — open-source notification infrastructure:

Setup and trigger

import { Novu } from "@novu/node"

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

// Trigger a notification workflow:
await novu.trigger("package-update", {
  to: {
    subscriberId: "user-123",
    email: "royce@example.com",
    firstName: "Royce",
  },
  payload: {
    packageName: "react",
    oldVersion: "18.3.1",
    newVersion: "19.0.0",
    changelogUrl: "https://pkgpulse.com/react/changelog",
  },
})

// Trigger to multiple subscribers:
await novu.trigger("weekly-digest", {
  to: [
    { subscriberId: "user-123" },
    { subscriberId: "user-456" },
    { subscriberId: "user-789" },
  ],
  payload: {
    week: "2026-W10",
    topPackages: ["react", "vue", "svelte"],
    totalUpdates: 150,
  },
})

Subscriber management

import { Novu } from "@novu/node"

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

// Create/update subscriber:
await novu.subscribers.identify("user-123", {
  email: "royce@example.com",
  firstName: "Royce",
  lastName: "Claw",
  phone: "+1234567890",
  avatar: "https://example.com/avatar.png",
  data: {
    plan: "pro",
    timezone: "America/New_York",
    trackedPackages: ["react", "vue", "next"],
  },
})

// Set subscriber preferences:
await novu.subscribers.setPreference("user-123", "package-update", {
  channel: {
    email: true,
    in_app: true,
    sms: false,
    push: false,
    chat: false,
  },
})

// Get subscriber notifications:
const feed = await novu.subscribers.getNotificationsFeed("user-123", {
  page: 0,
  limit: 20,
})

feed.data.forEach((notification) => {
  console.log(`${notification.templateIdentifier}: ${notification.content}`)
})

// Mark notification as read:
await novu.subscribers.markMessageAs("user-123", "message-id", {
  seen: true,
  read: true,
})

Workflow definition (code-first)

import { workflow } from "@novu/framework"

// Define notification workflow:
const packageUpdate = workflow("package-update", async ({ step, payload }) => {
  // In-app notification (always):
  await step.inApp("in-app-alert", async () => ({
    body: `${payload.packageName} updated to ${payload.newVersion}`,
    avatar: `https://pkgpulse.com/icons/${payload.packageName}.svg`,
    redirect: { url: payload.changelogUrl },
  }))

  // Email (digest — batch over 1 hour):
  await step.digest("batch-updates", async () => ({
    amount: 1,
    unit: "hours",
  }))

  await step.email("email-digest", async (inputs) => ({
    subject: `${inputs.steps.length} package updates`,
    body: `
      <h2>Package Updates</h2>
      ${inputs.steps.map((s: any) => `
        <p><strong>${s.payload.packageName}</strong>: ${s.payload.newVersion}</p>
      `).join("")}
    `,
  }))

  // SMS for critical updates only:
  await step.sms("sms-critical", async () => ({
    body: `🚨 ${payload.packageName} ${payload.newVersion} — breaking changes detected`,
  }), {
    skip: () => !payload.isBreaking,
  })
})

React in-app notifications

import { NovuProvider, PopoverNotificationCenter, NotificationBell } from "@novu/notification-center"

function App() {
  return (
    <NovuProvider
      subscriberId="user-123"
      applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APP_ID!}
    >
      <Header />
    </NovuProvider>
  )
}

function Header() {
  return (
    <nav>
      <PopoverNotificationCenter
        colorScheme="dark"
        onNotificationClick={(notification) => {
          window.location.href = notification.cta?.data?.url || "/"
        }}
      >
        {({ unseenCount }) => <NotificationBell unseenCount={unseenCount} />}
      </PopoverNotificationCenter>
    </nav>
  )
}

Knock

Knock — developer-friendly notifications:

Trigger workflows

import { Knock } from "@knocklabs/node"

const knock = new Knock(process.env.KNOCK_API_KEY!)

// Trigger a workflow:
await knock.workflows.trigger("package-update", {
  recipients: ["user-123"],
  data: {
    packageName: "react",
    newVersion: "19.0.0",
    changelogUrl: "https://pkgpulse.com/react/changelog",
    isBreaking: true,
  },
  actor: "system",  // Who triggered it
})

// Trigger for multiple recipients:
await knock.workflows.trigger("weekly-report", {
  recipients: ["user-123", "user-456", "user-789"],
  data: {
    week: "2026-W10",
    totalUpdates: 150,
    topPackages: ["react", "vue", "svelte"],
  },
})

// Trigger with tenant context:
await knock.workflows.trigger("team-alert", {
  recipients: ["user-123"],
  tenant: "org-456",  // Multi-tenant support
  data: {
    alert: "New security advisory for lodash",
  },
})

User and preference management

import { Knock } from "@knocklabs/node"

const knock = new Knock(process.env.KNOCK_API_KEY!)

// Identify user:
await knock.users.identify("user-123", {
  name: "Royce",
  email: "royce@example.com",
  phone_number: "+1234567890",
  avatar: "https://example.com/avatar.png",
  properties: {
    plan: "pro",
    timezone: "America/New_York",
  },
})

// Set channel data (push tokens, Slack channels):
await knock.users.setChannelData("user-123", "apns-channel-id", {
  tokens: ["device-token-abc"],
})

await knock.users.setChannelData("user-123", "slack-channel-id", {
  connections: [
    {
      incoming_webhook: { url: "https://hooks.slack.com/..." },
    },
  ],
})

// Get user preferences:
const prefs = await knock.users.getPreferences("user-123")

// Set preferences:
await knock.users.setPreferences("user-123", {
  workflows: {
    "package-update": {
      channel_types: {
        email: true,
        in_app_feed: true,
        sms: false,
        push: false,
      },
    },
    "weekly-report": {
      channel_types: {
        email: true,
        in_app_feed: false,
      },
    },
  },
})

Batching and schedules

import { Knock } from "@knocklabs/node"

const knock = new Knock(process.env.KNOCK_API_KEY!)

// Batch notifications (configured in Knock dashboard workflow):
// Multiple triggers within the batch window get combined:
await knock.workflows.trigger("package-update", {
  recipients: ["user-123"],
  data: { packageName: "react", newVersion: "19.0.1" },
})

await knock.workflows.trigger("package-update", {
  recipients: ["user-123"],
  data: { packageName: "next", newVersion: "15.1.0" },
})
// → Combined into one notification: "2 packages updated"

// Schedule a workflow:
await knock.workflows.trigger("weekly-report", {
  recipients: ["user-123"],
  data: { week: "2026-W11" },
  schedule: {
    // Send next Monday at 9am ET:
    timestamp: "2026-03-16T09:00:00-04:00",
  },
})

// Cancel a workflow:
await knock.workflows.cancel("package-update", {
  recipients: ["user-123"],
  cancellation_key: "update-react-19.0.0",
})

// List messages for a user:
const messages = await knock.users.getMessages("user-123", {
  channel_id: "in-app-feed-channel",
  status: "unread",
})

React in-app feed

import { KnockProvider, KnockFeedProvider, NotificationIconButton, NotificationFeedPopover } from "@knocklabs/react"
import "@knocklabs/react/dist/index.css"

function App() {
  return (
    <KnockProvider
      apiKey={process.env.NEXT_PUBLIC_KNOCK_PUBLIC_KEY!}
      userId="user-123"
    >
      <KnockFeedProvider feedId={process.env.NEXT_PUBLIC_KNOCK_FEED_ID!}>
        <Header />
      </KnockFeedProvider>
    </KnockProvider>
  )
}

function Header() {
  const [isOpen, setIsOpen] = useState(false)
  const buttonRef = useRef<HTMLButtonElement>(null)

  return (
    <nav>
      <NotificationIconButton
        ref={buttonRef}
        onClick={() => setIsOpen(!isOpen)}
      />
      <NotificationFeedPopover
        buttonRef={buttonRef}
        isVisible={isOpen}
        onClose={() => setIsOpen(false)}
        onNotificationClick={(item) => {
          window.location.href = item.data?.url || "/"
        }}
      />
    </nav>
  )
}

// Real-time updates (WebSocket):
import { useKnockFeed } from "@knocklabs/react"

function NotificationCount() {
  const { feedClient, useFeedStore } = useKnockFeed()
  const { unseenCount } = useFeedStore()

  // feedClient automatically connects via WebSocket
  // New notifications appear in real-time

  return <span className="badge">{unseenCount}</span>
}

Courier

Courier — notification orchestration:

Send notifications

import { CourierClient } from "@trycourier/courier"

const courier = CourierClient({ authorizationToken: process.env.COURIER_API_KEY! })

// Send a notification:
const { requestId } = await courier.send({
  message: {
    to: {
      user_id: "user-123",
      email: "royce@example.com",
    },
    template: "PACKAGE_UPDATE",  // Template ID from Courier designer
    data: {
      packageName: "react",
      newVersion: "19.0.0",
      changelogUrl: "https://pkgpulse.com/react/changelog",
    },
  },
})

// Send to multiple channels:
const { requestId: multiId } = await courier.send({
  message: {
    to: {
      user_id: "user-123",
    },
    content: {
      title: "Package Update: {{packageName}}",
      body: "{{packageName}} has been updated to {{newVersion}}. {{changelogUrl}}",
    },
    routing: {
      method: "all",  // Send to all channels
      channels: ["email", "push", "inbox"],
    },
    data: {
      packageName: "react",
      newVersion: "19.0.0",
      changelogUrl: "https://pkgpulse.com/react/changelog",
    },
  },
})

// Inline content (no template needed):
await courier.send({
  message: {
    to: { email: "royce@example.com" },
    content: {
      title: "🚨 Breaking Change Alert",
      body: "react 19.0.0 contains breaking changes. Review before upgrading.",
    },
    channels: { email: { override: { subject: "Breaking Change: react 19.0.0" } } },
  },
})

User profiles and preferences

import { CourierClient } from "@trycourier/courier"

const courier = CourierClient({ authorizationToken: process.env.COURIER_API_KEY! })

// Create/update user profile:
await courier.users.put("user-123", {
  profile: {
    email: "royce@example.com",
    phone_number: "+1234567890",
    name: "Royce",
    custom: {
      plan: "pro",
      timezone: "America/New_York",
      trackedPackages: ["react", "vue", "next"],
    },
  },
})

// Set push token:
await courier.users.tokens.put("user-123", "device-token-abc", {
  provider_key: "apn",  // apn | firebase-fcm
  device: {
    app_id: "com.pkgpulse.app",
    platform: "ios",
  },
})

// User preferences (subscription topics):
await courier.users.preferences.put("user-123", {
  topics: {
    "package-updates": { status: "OPTED_IN" },
    "weekly-reports": { status: "OPTED_IN" },
    "marketing": { status: "OPTED_OUT" },
  },
})

// List user preferences:
const prefs = await courier.users.preferences.list("user-123")
prefs.items.forEach((topic) => {
  console.log(`${topic.topic_id}: ${topic.status}`)
})

Automation and routing

import { CourierClient } from "@trycourier/courier"

const courier = CourierClient({ authorizationToken: process.env.COURIER_API_KEY! })

// Smart routing — try email, fall back to SMS:
await courier.send({
  message: {
    to: { user_id: "user-123" },
    template: "CRITICAL_ALERT",
    data: { alert: "Security vulnerability in lodash" },
    routing: {
      method: "single",  // Try channels in order, stop on first success
      channels: ["email", "sms", "push"],
    },
    timeout: {
      // Escalate if not read within 30 minutes:
      message: 1800000,  // 30 min in ms
      channel: {
        email: 600000,    // Try email for 10 min
      },
      escalation: "sms",  // Then escalate to SMS
    },
  },
})

// Bulk send:
const { jobId } = await courier.bulk.createJob({
  message: {
    template: "WEEKLY_REPORT",
    data: {
      week: "2026-W10",
      totalUpdates: 150,
    },
  },
})

// Add recipients to bulk job:
await courier.bulk.ingestUsers(jobId, {
  users: [
    { user_id: "user-123", data: { personalizedContent: "..." } },
    { user_id: "user-456", data: { personalizedContent: "..." } },
    // ... thousands of users
  ],
})

// Run the bulk job:
await courier.bulk.runJob(jobId)

React inbox component

import { CourierProvider, Inbox } from "@trycourier/react-provider"

function App() {
  return (
    <CourierProvider
      clientKey={process.env.NEXT_PUBLIC_COURIER_CLIENT_KEY!}
      userId="user-123"
    >
      <Header />
    </CourierProvider>
  )
}

function Header() {
  return (
    <nav>
      <Inbox
        title="Notifications"
        theme={{
          brand: { colors: { primary: "#3B82F6" } },
        }}
        onNotificationClick={(notification) => {
          window.location.href = notification.data?.url || "/"
        }}
        renderIcon={(unreadCount) => (
          <button className="relative">
            🔔
            {unreadCount > 0 && (
              <span className="badge">{unreadCount}</span>
            )}
          </button>
        )}
      />
    </nav>
  )
}

// Toast notifications:
import { Toast } from "@trycourier/react-toast"

function NotificationToasts() {
  return <Toast position="top-right" autoClose={5000} />
}

Feature Comparison

FeatureNovuKnockCourier
Open-source
Self-hosted
Email
SMS
Push notifications
In-app notifications✅ (Inbox)
Chat (Slack/Discord)
Workflow engine✅ (code-first)✅ (dashboard)✅ (designer)
Digest/batch
User preferences✅ (topics)
Template designer✅ (basic)❌ (code)✅ (visual, drag-and-drop)
Multi-tenancy
Real-time WebSocket
React components
Smart routing✅ (conditions)✅ (escalation)
Bulk send
Free tier30K events/mo10K msgs/mo10K msgs/mo
PricingEvent-basedMessage-basedMessage-based

When to Use Each

Use Novu if:

  • Want open-source notification infrastructure you can self-host
  • Need a code-first workflow engine with full control
  • Prefer owning your notification stack
  • Building with TypeScript and want framework-level flexibility

Use Knock if:

  • Want the best developer experience for fast integration
  • Need real-time in-app notification feeds with React components
  • Want powerful batching and scheduling out of the box
  • Building multi-tenant SaaS with per-tenant notification preferences

Use Courier if:

  • Want a visual drag-and-drop template designer
  • Need smart routing with escalation and fallback channels
  • Building enterprise products with complex notification logic
  • Want non-engineers to manage notification templates

Self-Hosting Novu in Production

Novu is the only platform in this comparison that can be fully self-hosted, which is significant for organizations with data residency requirements or budgets that make per-message SaaS pricing impractical at scale. A production Novu deployment requires several components: a PostgreSQL database for application data, Redis for queuing and caching, a MongoDB instance for notification history storage, and the Novu API, Worker, and Web application containers. The recommended deployment approach uses Docker Compose for small teams or Kubernetes Helm charts for production scale. One important operational consideration is that Novu's worker process handles the actual notification dispatching — running multiple worker replicas with Redis-based queue coordination allows horizontal scaling of notification throughput. For teams self-hosting, email delivery still requires an external SMTP provider or transactional email service (SendGrid, Resend, Postmark) configured through Novu's provider integration system.

TypeScript SDK Design and Type Safety

The TypeScript experience differs meaningfully across these notification platforms. Novu's @novu/framework package for code-first workflow definitions provides excellent TypeScript integration — workflow steps are typed to the specific channel's payload schema, and the payload variable in step handlers is automatically typed based on the workflow's defined payload schema. Knock's SDK is well-typed for the core trigger and user management API surface, and its React components use generic TypeScript to allow typing the notification data shape for rendering. Courier's SDK is more loosely typed than the others — the message object in courier.send() uses a fairly permissive type structure that doesn't enforce the template-specific field requirements at compile time. For teams that prioritize compile-time safety over flexibility in their notification integration, Novu's framework-level workflow definitions provide the strongest TypeScript guarantees.

Digest and Batching Behavior in Production

Notification batching and digest is a powerful but operationally complex feature that requires careful design. Novu's digest step buffers incoming trigger events for a configured time window and then emits a single consolidated notification — for example, batching five "new comment" events into "5 new comments on your post." The digest window is stored in Redis and reset by each new event, which means very active users may never receive a notification if events keep arriving before the window closes (the digestUntilDefined option addresses this with a maximum wait time). Knock's batch step behaves similarly and adds a batch_size limit that forces digest delivery once a certain number of events accumulate, providing a ceiling on notification delay. Courier's batch handling is less granular and works primarily at the routing level rather than within individual workflow steps. For high-volume notification scenarios (social platforms, collaborative tools), the digest behavior under load should be tested specifically, because digest implementation details significantly affect the user experience when notification rates are high.

Multi-Tenancy and Organizational Notifications

SaaS applications with organizational accounts often need to send notifications both to individual users and to entire organizations, with the organization able to configure notification preferences for its members. Knock is the most purpose-built for this use case through its Tenants system — organizations are first-class entities, and notification preferences can be set at the tenant level, overriding or supplementing user-level preferences. An organization can disable certain notification types for all its members centrally, or set specific channel preferences that override individual user settings. Novu's topic system provides a similar broadcast mechanism, where triggering a notification to a topic delivers it to all subscribers of that topic, but the per-tenant preference override model is less developed than Knock's. Courier supports multi-tenancy through its workspace isolation model, primarily useful for white-labeling Courier as part of a product rather than for per-tenant preference configuration within a single workspace.

Email Template Strategy and Provider Portability

One underappreciated aspect of notification infrastructure is provider portability — what happens when you need to switch email providers or SMS carriers. Novu's provider abstraction is the strongest here: you configure email providers (SendGrid, Mailgun, Resend, AWS SES) through the Novu dashboard, and your application code never references the provider directly. Switching providers is a dashboard operation, not a code change. Knock uses the same provider abstraction model, with provider integrations configured in the Knock dashboard and activated per notification channel. Courier takes a similar approach but adds routing rules that can automatically fail over to a secondary provider if the primary fails or if deliverability drops below a threshold — a useful production reliability feature for high-volume transactional email. Novu's code-first workflow system using @novu/framework stores workflow logic in your codebase rather than in the provider dashboard, which makes it easier to version control and code review changes to notification flows, a meaningful developer experience advantage over dashboard-only workflow editors.

Notification Volume and Cost Modeling

Understanding the cost structure of each platform matters significantly at production volumes. Novu Cloud's pricing is event-based — you pay per notification event triggered, with a free tier of 30,000 events per month. At higher volumes, the per-event cost decreases, but for applications sending hundreds of notifications per active user per month (activity feeds, collaboration alerts), costs accumulate quickly. Knock's pricing is similarly event-based, with a free tier that covers development and early production. The cost-control lever for both platforms is intelligent notification batching and digesting: instead of sending 50 individual email notifications when a document receives 50 comments, a digest delivers one summary email with all 50 comments. This reduces event count (lowering your bill), reduces user fatigue, and often improves engagement metrics. Novu's self-hosted deployment eliminates the per-event cost entirely, replacing it with infrastructure costs — typically $50-200/month for a well-sized self-hosted deployment on a cloud provider. For applications with predictable, high notification volumes, self-hosting becomes economically favorable above roughly 1-2 million events per month.


Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on @novu/node v2.x, @knocklabs/node v0.x, and @trycourier/courier v6.x.

Compare notification and developer tooling on PkgPulse →

See also: AVA vs Jest and Medusa vs Saleor vs Vendure 2026, Cal.com vs Calendly vs Nylas (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.