Notifications
Overview
The kit ships a unified notification system: one service delivers to two channels — the in-app bell and email — and a per-user preference governs both. Categories group notifications the user can toggle together, and an unsubscribe link handles one-click email opt-out.
- In-app: a bell in the app header with an unread badge and a panel.
- Email: sent through the same
notify()call when the email channel is on. - Preferences:
account → notifications— a toggle per category × channel. - Unsubscribe: a signed, login-free link for email footers.
Categories
Defined in src/config/notifications.config.ts. Each has default channel
delivery and a forced flag:
| Category | Forced | Default in-app | Default email |
|---|---|---|---|
security | yes | on | on |
team | no | on | on |
billing | no | on | on |
product | no | on | off |
Forced categories (security) always deliver and can't be turned off — shown
locked in the preferences UI. product is the non-transactional category the
unsubscribe link primarily targets.
Sending a notification
Call notify() from any server code (a router, a webhook, an auth hook). It
resolves the user's preference and delivers on the enabled channels — in-app
always writes a bell row; the email is sent only if email content is passed
and the email channel is on. It never throws.
import { notify } from "@/lib/notifications"
await notify({
userId,
category: "team",
type: "team.role_changed", // renders notifications.items.team.role_changed.*
data: { orgName, role }, // params for the localized title/body
link: "/dashboard", // optional in-app click-through
email: { subject, html }, // optional — only sent if email channel on
})
Notification text is rendered at display time from type + data via
i18n (notifications.items.<type>.{title,body}), so it always matches the
reader's current locale.
Wired triggers
Out of the box: team.role_changed (org role change), billing.payment_failed
(Stripe webhook), security.new_login (every sign-in) and
security.api_key_created. Add your own by calling notify() at the event and
adding the items.<type> i18n keys.
Unsubscribe
unsubscribeUrl(userId, category) builds a signed, stateless link
(/unsubscribe?token=…) — the token is the capability, so it works without a
session. Put it in the footer of any non-transactional email. The page confirms,
then turns the email channel off for that category (in-app is untouched).
Transactional emails (password reset, invitations, receipts) never get an
unsubscribe link — they're mandatory and bypass this system entirely.
Adding a category
Add an entry to notificationCategories, add its categories.<key> i18n
labels, and pass its key to notify(). Forced categories skip the preference
lookup and always deliver.