npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

convex-notifications

v1.9.2

Published

A notifications component for Convex.

Readme

Convex Notifications

npm version

A full-stack notifications engine for Convex apps. Real-time inbox, multi-channel delivery (push, email, SMS), user preferences, and deduplication — all as a single installable component.

Features:

  • Real-time inbox with list, unreadCount, markRead, markAllRead, archive
  • Multi-channel delivery: push (Expo), email (Resend), SMS (Twilio)
  • NotificationDefinition<T> types — define an event in one file (~20 lines)
  • api() method for plug-and-play query/mutation exports (no boilerplate!)
  • 3-level user preferences: global > category > event
  • Transactional notifications that bypass preferences
  • Idempotency via deduplication keys
  • Push token registration passthrough
  • React hooks for inbox and preference management
  • React Email support for rich email templates

Found a bug? Feature request? File it here.

Installation

npm install convex-notifications

Peer dependencies:

npm install convex react

Quick Start

1. Install the component

// convex/convex.config.ts
import { defineApp } from "convex/server";
import notifications from "convex-notifications/convex.config.js";

const app = defineApp();
app.use(notifications);
export default app;

2. Create your notifications API

// convex/notifications.ts
import { Notifications } from "convex-notifications";
import { components } from "./_generated/api";

const notifications = new Notifications(components.notifications, {
  auth: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Not authenticated");
    return userId;
  },
  resolvers: {
    email: async (ctx, userId) => {
      const user = await ctx.db.get(userId);
      return user?.email ?? null;
    },
    phone: async (ctx, userId) => {
      const user = await ctx.db.get(userId);
      return user?.phone ?? null;
    },
    pushToken: async (ctx, userId) => {
      const user = await ctx.db.get(userId);
      return user?.pushToken ?? null;
    },
  },
});

// Use api() for plug-and-play exports (no boilerplate!)
export const {
  list,
  unreadCount,
  markRead,
  markAllRead,
  archive,
  getPreferences,
  updatePreference,
} = notifications.api();

3. Deploy

npx convex deploy

Defining Notification Events

Use createNotification<T>() to define each event type in a single file. Templates receive only data — the engine resolves user addresses automatically via your configured resolvers.

// convex/notifications/commentReply.ts
import { createNotification } from "convex-notifications";
import { v } from "convex/values";

export const commentReplyNotification = createNotification({
  event: "comment.reply",
  dataValidator: v.object({
    commenterName: v.string(),
    postTitle: v.string(),
  }),
  category: "social",
  channels: {
    inbox: {
      title: (data) => `${data.commenterName} replied`,
      body: (data) => `New reply on "${data.postTitle}"`,
    },
    email: {
      subject: (data) => `${data.commenterName} replied to your comment`,
      body: (data) => `${data.commenterName} replied on "${data.postTitle}".`,
    },
    push: {
      title: (data) => `New reply`,
      body: (data) => `${data.commenterName} replied on "${data.postTitle}"`,
    },
  },
});

Sending Notifications

Call .send() from any mutation or action:

import { commentReplyNotification } from "./notifications/commentReply";

export const replyToComment = mutation({
  args: { commentId: v.id("comments"), text: v.string() },
  handler: async (ctx, args) => {
    const comment = await ctx.db.get(args.commentId);

    await commentReplyNotification.send(ctx, {
      userId: comment.authorId,
      data: {
        commenterName: "Alice",
        postTitle: comment.postTitle,
      },
    });
  },
});

Transactional Notifications

Add transactional: true to bypass user preferences (for password resets, security alerts, etc.):

await passwordResetNotification.send(ctx, {
  userId,
  data: { resetLink },
  transactional: true,
});

Deduplication

Prevent duplicate sends with a deduplication key:

await notification.send(ctx, {
  userId,
  data,
  deduplicationKey: `comment-reply:${commentId}`,
});

Inbox

Queries

// List notifications (paginated)
const results = useQuery(api.notifications.list, {
  limit: 20,
  cursor: null,
});

// Unread count
const count = useQuery(api.notifications.unreadCount);

Mutations

const markRead = useMutation(api.notifications.markRead);
const markAllRead = useMutation(api.notifications.markAllRead);
const archiveNotification = useMutation(api.notifications.archive);

// Mark single notification as read
await markRead({ notificationId });

// Mark all as read (timestamp-based)
await markAllRead({});

// Archive a notification
await archiveNotification({ notificationId });

Preferences

Users can control which channels are enabled at three levels: global, category, and event. The most specific setting wins.

// Get current preferences
const prefs = useQuery(api.notifications.getPreferences);

// Update preferences
const update = useMutation(api.notifications.updatePreference);

// Disable email globally
await update({ level: "global", channel: "email", enabled: false });

// Enable email for a specific category
await update({ level: "category", key: "social", channel: "email", enabled: true });

// Disable push for a specific event
await update({ level: "event", key: "comment.reply", channel: "push", enabled: false });

Push Token Registration

Pass through push tokens to the underlying expo-push-notifications component:

const registerToken = useMutation(api.notifications.registerPushToken);
await registerToken({ token: expoPushToken });

React Hooks

import { useNotifications, useUnreadCount, usePreferences } from "convex-notifications/react";

function NotificationBell() {
  const { notifications, loadMore, status } = useNotifications();
  const unreadCount = useUnreadCount();

  return (
    <div>
      <span>({unreadCount})</span>
      {notifications.map((n) => (
        <div key={n._id}>{n.title}</div>
      ))}
      {status === "CanLoadMore" && <button onClick={loadMore}>Load more</button>}
    </div>
  );
}

React Email Support

Use the html field to render rich HTML emails. This works with React Email's render() function or any other HTML-producing tool:

import { render } from "@react-email/components";
import WelcomeEmail from "./emails/WelcomeEmail";

export const welcomeNotification = createNotification({
  event: "user.welcome",
  dataValidator: v.object({ userName: v.string() }),
  channels: {
    email: {
      subject: (data) => `Welcome, ${data.userName}`,
      body: (data) => `Welcome ${data.userName}! Thanks for joining.`, // Plain text fallback
      html: (data) => render(<WelcomeEmail userName={data.userName} />),
    },
    inbox: {
      title: (data) => `Welcome, ${data.userName}!`,
      body: () => `Thanks for joining.`,
    },
  },
});

The html field supports both sync and async functions, so you can use await render() if needed:

email: {
  subject: (data) => `Welcome, ${data.userName}`,
  body: (data) => `Plain text version`,
  html: async (data) => await render(<WelcomeEmail userName={data.userName} />),
},

API Reference

| Function | Type | Auth | Description | |---|---|---|---| | list | query | required | Paginated inbox notifications | | unreadCount | query | required | Count of unread notifications | | markRead | mutation | required | Mark a notification as read | | markAllRead | mutation | required | Mark all notifications as read (timestamp-based) | | archive | mutation | required | Archive a notification | | getPreferences | query | required | Get user's notification preferences | | updatePreference | mutation | required | Update channel preferences (global/category/event) |

Configuration

interface NotificationsOptions {
  /** Resolve the current user ID from the request context. */
  auth: (ctx: { auth: Auth }) => Promise<string>;

  /** Resolve delivery addresses per channel. Return null to skip the channel. */
  resolvers?: {
    email?: (ctx: { auth: Auth }, userId: string) => Promise<string | null>;
    phone?: (ctx: { auth: Auth }, userId: string) => Promise<string | null>;
    pushToken?: (ctx: { auth: Auth }, userId: string) => Promise<string | null>;
  };
}

Architecture

User Event (mutation/action)
  │
  └─ createNotification().send(ctx, { userId, data })
       │
       ├─ Create inbox record (always)
       ├─ Check transactional flag
       ├─ Resolve preferences (global → category → event)
       │
       └─ For each enabled channel:
            ├─ Render template with data
            ├─ Resolve address via config resolvers
            └─ Dispatch to child component
                 ├─ expo-push-notifications (push)
                 ├─ resend (email)
                 └─ twilio (SMS)

Troubleshooting

TypeScript errors after installation

Run codegen to regenerate types:

npx convex dev

Auth provider mismatch

The auth function in new Notifications() must match your auth setup. For Convex Auth:

auth: async (ctx) => {
  const userId = await getAuthUserId(ctx);
  if (!userId) throw new Error("Not authenticated");
  return userId;
},

For Clerk:

auth: async (ctx) => {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error("Not authenticated");
  return identity.subject;
},

Notifications not delivering to a channel

  1. Verify the resolver returns a non-null value for that channel
  2. Check that user preferences have the channel enabled
  3. For transactional notifications, ensure transactional: true is set
  4. Check the delivery log for error details

Local Development

npm i
npm run dev

This starts parallel processes for the Convex backend, Vite frontend, and component build watcher. Changes to src/ trigger automatic rebuilds.

Running Tests

npm run test           # Unit + integration tests
npm run test:all       # Full suite: tests + export validation + consumer integration

npm run test:all includes consumer integration tests that install the package from a tarball and verify all exports, types, and the ComponentApi boundary work correctly for real consumers.

See CONTRIBUTING.md for the full development guide.