convex-notifications
v1.9.2
Published
A notifications component for Convex.
Readme
Convex Notifications
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-notificationsPeer dependencies:
npm install convex reactQuick 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 deployDefining 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 devAuth 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
- Verify the resolver returns a non-null value for that channel
- Check that user preferences have the channel enabled
- For transactional notifications, ensure
transactional: trueis set - Check the delivery log for error details
Local Development
npm i
npm run devThis 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 integrationnpm 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.
