Launch NowDocs

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".

FileRole
lib/notifications/events.tsCatalog of categories (NOTIFICATION_CATEGORIES), audiences and event types (NOTIFICATION_EVENTS)
lib/notifications/server.tsnotify(), email delivery, list/count/mark queries, preferences, broadcast history
lib/notification-actions.tsServer actions called by the UI
features/notifications/notification-bell.tsxBell popover in the app header
features/notifications/notification-inbox.tsxFull inbox at /notifications
features/notifications/notification-preferences.tsxPreferences card at /account/notifications
features/notifications/use-notifications.tsTanStack Query hooks with polling and optimistic updates
features/notifications/broadcast-composer.tsx, broadcast-history.tsxAdmin broadcast page
features/notifications/organization-announce-dialog.tsxAnnouncement dialog for organization owners and admins

When you call notify():

  1. The event's render function turns your data into a title, optional body and optional in-app link.
  2. A notification row is inserted with the category, audience and target.
  3. 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:

lib/notifications/events.ts
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" }
AudienceWho sees it
userThe one user
organizationMembers of the organization, optionally only the listed roles
adminsPlatform admins (user.role = 'admin')
allEvery 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.

CategoryLabelIn-app defaultEmail defaultNotes
organizationOrganizationsonon
billingBillingonon
securitySecurityononlocked: always on, can't be turned off
adminPlatform adminonoffadminOnly: hidden from non-admins
announcementsAnnouncementsonoff

Built-in events

These events are already wired up in the boilerplate:

TypeCategorySent whenSent from
org.invitation_receivedorganizationAn existing user is invited to an organizationlib/auth.ts (afterCreateInvitation)
org.member_joinedorganizationSomeone accepts an invitation (to owners and admins)lib/auth.ts (afterAcceptInvitation)
org.member_leftorganizationA member leaveslib/organization-actions.ts
org.role_changedorganizationA member's role changeslib/organization-actions.ts
org.member_removedorganizationA member is removedlib/organization-actions.ts
billing.subscription_startedbillingA subscription completeslib/auth.ts (Stripe plugin)
billing.subscription_canceledbillingA subscription is canceledlib/auth.ts (Stripe plugin)
billing.payment_failedbillingStripe sends invoice.payment_failedlib/auth.ts (Stripe plugin)
security.new_sign_insecurityA sign-in from an unseen device and IP pairlib/auth.ts (session hook)
security.two_factor_changedsecurity2FA is enabled or disabledlib/auth.ts (user hook)
security.api_key_createdsecurityAn API key is createdlib/auth.ts (after hook)
admin.user_signed_upadminA user is createdlib/auth.ts (user hook)
admin.organization_createdadminAn organization is createdlib/auth.ts (afterCreateOrganization)
admin.feedback_receivedadminFeedback is submittedlib/feedback-actions.ts
announcementannouncementsAn admin broadcast or organization announcementlib/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:

lib/notifications/events.ts
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:

OptionTypeDescription
targetNotificationTargetWho receives it
dataevent dataPassed to the event's render
actorIdstring (optional)The user who caused it. Shown as the avatar and name in the inbox, and never emailed their own notification
emailboolean (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: true was 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:

KeyAction
J / KNext / previous notification
EArchive or unarchive
UToggle read / unread
EnterOpen the notification's link
/Focus search
EscapeClear 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:

lib/notification-actions.ts
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:

ActionWhoPurpose
fetchNotificationsAny userPage of notifications plus unread count. Input: unreadOnly, archived, categories, before (ISO date), limit (1–50)
fetchNotificationAny userOne notification, if the user can see it
markNotificationsReadAny userids (up to 100) or all, with action: read, unread, archive or unarchive
fetchNotificationPreferencesAny userPreferences per category
updateNotificationPreferenceAny usercategory, inApp, email
sendBroadcastPlatform adminSend an announcement
fetchBroadcastsPlatform adminBroadcast history
sendOrganizationAnnouncementOrg owner or adminAnnounce to an organization

See Server actions for the full list and Database schema for the three notification tables.

Next steps