Notifications
In-app notifications with a bell, an inbox, per-category preferences, email delivery, admin broadcasts and organization announcements.
Launch Now includes a notification system that stores events in Postgres, shows them in a bell and a full inbox, and emails them through Resend according to each user's preferences. Use it to tell users about anything that happens in your product: an invitation, a failed payment, a new sign-in, or an announcement from your team.
How it works
A notification is one row in the notification table with an audience. Per-user state (read, archived) lives in notification_receipt, so a broadcast to every user stays a single row. Preferences live in notification_preference, one row per user and category, and a missing row means "use the category default".
| File | Role |
|---|---|
lib/notifications/events.ts | Catalog of categories (NOTIFICATION_CATEGORIES), audiences and event types (NOTIFICATION_EVENTS) |
lib/notifications/server.ts | notify(), email delivery, list/count/mark queries, preferences, broadcast history |
lib/notification-actions.ts | Server actions called by the UI |
features/notifications/notification-bell.tsx | Bell popover in the app header |
features/notifications/notification-inbox.tsx | Full inbox at /notifications |
features/notifications/notification-preferences.tsx | Preferences card at /account/notifications |
features/notifications/use-notifications.ts | TanStack Query hooks with polling and optimistic updates |
features/notifications/broadcast-composer.tsx, broadcast-history.tsx | Admin broadcast page |
features/notifications/organization-announce-dialog.tsx | Announcement dialog for organization owners and admins |
When you call notify():
- The event's
renderfunction turns your data into a title, optional body and optional in-app link. - A
notificationrow is inserted with the category, audience and target. - After the response is sent (with Next.js
after()), recipients are resolved and emailed if their preferences allow it.
notify() never throws. A failed notification is logged with [notifications] and returns undefined, so it can't break the action that triggered it.
Audiences
A target is one of four audiences, defined by NotificationTarget:
export type NotificationTarget =
| { audience: "user"; userId: string }
| {
audience: "organization"
organizationId: string
/** Only these member roles; all members when omitted. */
roles?: ("owner" | "admin" | "member")[]
}
| { audience: "admins" }
| { audience: "all" }| Audience | Who sees it |
|---|---|
user | The one user |
organization | Members of the organization, optionally only the listed roles |
admins | Platform admins (user.role = 'admin') |
all | Every user. Users only see broadcasts created after their account was |
For email, all resolves to every user who isn't banned.
Categories
Every event belongs to a category. Categories drive the default delivery, the preferences screen and the inbox filter.
| Category | Label | In-app default | Email default | Notes |
|---|---|---|---|---|
organization | Organizations | on | on | |
billing | Billing | on | on | |
security | Security | on | on | locked: always on, can't be turned off |
admin | Platform admin | on | off | adminOnly: hidden from non-admins |
announcements | Announcements | on | off |
Built-in events
These events are already wired up in the boilerplate:
| Type | Category | Sent when | Sent from |
|---|---|---|---|
org.invitation_received | organization | An existing user is invited to an organization | lib/auth.ts (afterCreateInvitation) |
org.member_joined | organization | Someone accepts an invitation (to owners and admins) | lib/auth.ts (afterAcceptInvitation) |
org.member_left | organization | A member leaves | lib/organization-actions.ts |
org.role_changed | organization | A member's role changes | lib/organization-actions.ts |
org.member_removed | organization | A member is removed | lib/organization-actions.ts |
billing.subscription_started | billing | A subscription completes | lib/auth.ts (Stripe plugin) |
billing.subscription_canceled | billing | A subscription is canceled | lib/auth.ts (Stripe plugin) |
billing.payment_failed | billing | Stripe sends invoice.payment_failed | lib/auth.ts (Stripe plugin) |
security.new_sign_in | security | A sign-in from an unseen device and IP pair | lib/auth.ts (session hook) |
security.two_factor_changed | security | 2FA is enabled or disabled | lib/auth.ts (user hook) |
security.api_key_created | security | An API key is created | lib/auth.ts (after hook) |
admin.user_signed_up | admin | A user is created | lib/auth.ts (user hook) |
admin.organization_created | admin | An organization is created | lib/auth.ts (afterCreateOrganization) |
admin.feedback_received | admin | Feedback is submitted | lib/feedback-actions.ts |
announcement | announcements | An admin broadcast or organization announcement | lib/notification-actions.ts |
Billing notifications go to the organization's owners and admins when the subscription belongs to an organization, and to the user otherwise.
Sending a notification from code
To add your own event, declare it in the catalog, then call notify() where it happens.
Declare the event
Add an entry to NOTIFICATION_EVENTS with a category and a render function. The generic type is the data you'll pass in:
export const NOTIFICATION_EVENTS = {
// ...
"project.exported": define<{ projectName: string; projectId: string }>({
category: "organization",
render: (d) => ({
title: `${d.projectName} is ready to download`,
body: "Your export finished.",
link: `/projects/${d.projectId}`,
}),
}),
}link should be an in-app path. It becomes the row's link in the inbox and, prefixed with BETTER_AUTH_URL, the Open button in the email.
Call notify()
Call notify() from a server action, a route handler, an auth hook or a script:
import { notify } from "@/lib/notifications/server"
await notify("project.exported", {
target: { audience: "user", userId: ctx.user.id },
data: { projectName: project.name, projectId: project.id },
actorId: ctx.user.id,
})The data type is checked against the event you declared.
notify() takes these options:
| Option | Type | Description |
|---|---|---|
target | NotificationTarget | Who receives it |
data | event data | Passed to the event's render |
actorId | string (optional) | The user who caused it. Shown as the avatar and name in the inbox, and never emailed their own notification |
email | boolean (optional) | true emails even when the category's email default is off. Opt-outs still apply. Required to email an all broadcast |
Good to know: Inside a request, emails are sent after the response with after(). In a script (no request), they're sent immediately.
To add a new category, add it to the NotificationCategory union and to NOTIFICATION_CATEGORIES, then add an icon for it in CATEGORY_ICONS (features/notifications/category.tsx).
Email delivery
Emails are sent with resend.batch.send, in batches of 100, from APP_NOREPLY_EMAIL when it's set and EMAIL_FROM otherwise. The subject is the notification title. The HTML is built by notificationEmailHtml in lib/notifications/server.ts and ends with a "Manage notifications" link to /account/notifications.
For each recipient, the email is sent when:
- The category is
locked(security), or - The user hasn't opted out of email for this category, and one of these is true:
email: truewas passed, the user opted in, or the category's email default is on.
An all broadcast is only emailed when email: true is passed.
The bell
NotificationBell sits in the app header next to the feedback button (app/(app)/layout.tsx). It shows a badge with the unread count (capped at 9+) and opens a popover with:
- All / Unread filters.
- Mark all as read.
- An archive button on each row, and arrow-key navigation between rows.
- Load more, View all (goes to
/notifications) and Settings (goes to/account/notifications).
Clicking a row marks it as read and follows its link. A notification without a link opens /notifications?id=….
The list is polled every 30 seconds and refetched on window focus (POLL_INTERVAL in use-notifications.ts). All notification queries share the ["notifications"] query key, so the bell and the inbox stay in sync. Read, unread, archive and unarchive are applied optimistically and rolled back on error.
The inbox
/notifications renders NotificationInbox: a list and a detail pane. The URL holds the state: ?tab=unread or ?tab=archived picks the view (the default is the inbox), and ?id=… selects a notification.
You can filter by category (the Platform admin category only appears for admins), search the loaded notifications by title and body, and select several rows for bulk actions.
Keyboard shortcuts:
| Key | Action |
|---|---|
J / K | Next / previous notification |
E | Archive or unarchive |
U | Toggle read / unread |
Enter | Open the notification's link |
/ | Focus search |
Escape | Clear the selection |
Preferences
/account/notifications renders NotificationPreferences and the newsletter card. Each category has an In-app and an Email switch. Security is marked Required and its switches are disabled. The Platform admin row only appears for admins.
Turning off In-app for a category hides its notifications from the bell, the inbox and the unread count. It doesn't delete them. Preferences are saved with updateNotificationPreference, which upserts a notification_preference row. setNotificationPreference ignores locked categories.
Broadcasts from the admin area
Platform admins send announcements from /admin/notifications. The composer takes:
- Audience: Everyone, Admins, An organization or A user (with a searchable picker).
- Title (up to 120 characters), Message (up to 1000) and an optional Link, which must be an in-app path starting with
/. - Also send by email.
It shows a live preview and asks for confirmation, then calls sendBroadcast. That action is guarded by an adminAction middleware and sends an announcement event with the admin as actor.
The history below lists the last 50 announcements (listSentBroadcasts) with their audience, sender and how many recipients have read them.
Organization announcements
Owners and admins of an organization can message their own members. On the members settings page (/orgs/[orgSlug]/settings/members), the Announce button opens OrganizationAnnounceDialog, which calls sendOrganizationAnnouncement:
await requireMembership(input.organizationId, user.id, ["owner", "admin"])
await notify("announcement", {
target: {
audience: "organization",
organizationId: input.organizationId,
},
actorId: user.id,
email: input.email,
data: { title: input.title, body: input.body || undefined },
})Organization announcements have a title, a body and an email toggle, but no link.
Server actions
All actions live in lib/notification-actions.ts and require a signed-in user:
| Action | Who | Purpose |
|---|---|---|
fetchNotifications | Any user | Page of notifications plus unread count. Input: unreadOnly, archived, categories, before (ISO date), limit (1–50) |
fetchNotification | Any user | One notification, if the user can see it |
markNotificationsRead | Any user | ids (up to 100) or all, with action: read, unread, archive or unarchive |
fetchNotificationPreferences | Any user | Preferences per category |
updateNotificationPreference | Any user | category, inApp, email |
sendBroadcast | Platform admin | Send an announcement |
fetchBroadcasts | Platform admin | Broadcast history |
sendOrganizationAnnouncement | Org owner or admin | Announce to an organization |
See Server actions for the full list and Database schema for the three notification tables.