A notifications component for Convex.
npm install 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 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.
``sh`
npm install convex-notifications
Peer dependencies:
`sh`
npm install convex react
`ts
// 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;
`
`ts
// 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();
`
`sh`
npx convex deploy
Use createNotification to define each event type in a single file. Templates receive only data — the engine resolves user addresses automatically via your configured resolvers.
`ts
// 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,New reply on "${data.postTitle}"
body: (data) => ,${data.commenterName} replied to your comment
},
email: {
subject: (data) => ,${data.commenterName} replied on "${data.postTitle}".
body: (data) => ,New reply
},
push: {
title: (data) => ,${data.commenterName} replied on "${data.postTitle}"
body: (data) => ,`
},
},
});
Call .send() from any mutation or action:
`ts
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,
},
});
},
});
`
Add transactional: true to bypass user preferences (for password resets, security alerts, etc.):
`ts`
await passwordResetNotification.send(ctx, {
userId,
data: { resetLink },
transactional: true,
});
Prevent duplicate sends with a deduplication key:
`tscomment-reply:${commentId}
await notification.send(ctx, {
userId,
data,
deduplicationKey: ,`
});
`ts
// List notifications (paginated)
const results = useQuery(api.notifications.list, {
limit: 20,
cursor: null,
});
// Unread count
const count = useQuery(api.notifications.unreadCount);
`
`ts
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 });
`
Users can control which channels are enabled at three levels: global, category, and event. The most specific setting wins.
`ts
// 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 });
`
Pass through push tokens to the underlying expo-push-notifications component:
`ts`
const registerToken = useMutation(api.notifications.registerPushToken);
await registerToken({ token: expoPushToken });
`ts
import { useNotifications, useUnreadCount, usePreferences } from "convex-notifications/react";
function NotificationBell() {
const { notifications, loadMore, status } = useNotifications();
const unreadCount = useUnreadCount();
return (
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:`ts
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( ),
},
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:`ts
email: {
subject: (data) => Welcome, ${data.userName},
body: (data) => Plain text version,
html: async (data) => await render( ),
},
`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
`ts
interface NotificationsOptions {
/* Resolve the current user ID from the request context. /
auth: (ctx: { auth: Auth }) => Promise; /* Resolve delivery addresses per channel. Return null to skip the channel. /
resolvers?: {
email?: (ctx: { auth: Auth }, userId: string) => Promise;
phone?: (ctx: { auth: Auth }, userId: string) => Promise;
pushToken?: (ctx: { auth: Auth }, userId: string) => Promise;
};
}
`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
$3
Run codegen to regenerate types:
`sh
npx convex dev
`$3
The
auth function in new Notifications() must match your auth setup. For Convex Auth:
`ts
auth: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
return userId;
},
`For Clerk:
`ts
auth: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return identity.subject;
},
`$3
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 detailsLocal Development
`sh
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.See CONTRIBUTING.md for the full development guide.