Skip to main content
Localizia

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:

CategoryForcedDefault in-appDefault email
securityyesonon
teamnoonon
billingnoonon
productnoonoff

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.