Notifications
UI Notifications
The UI consumes the API notifications subsystem. The feed is a TanStack Query infinite list, mutations are optimistic with rollback, and an optional SSE EventSource keeps the cache live while the user is authenticated. The bell + popover mount inside AppShell, so every authenticated route gets them for free.
SSE
live cache updates
Optimistic
mark-read mutations
Event-agnostic
pre-rendered strings
The backend ships pre-rendered { title, body, ctaUrl, ctaLabel } strings. The UI renders them as plain content, no per-event-type switches.
sequenceDiagram participant API participant SSE as EventSource (Browser) participant Hook as useNotificationStream participant Cache as TanStack Query cache participant Bell as NotificationBell participant Toast as Sonner Note over Hook,SSE: AppShell mounts the hook for every authenticated route Hook->>SSE: open /api/v1/notifications/stream API-->>SSE: PUBLISH notifications:user:<id> SSE-->>Hook: message event Hook->>Hook: parseStreamMessage (defensive) Hook->>Cache: prepend to list page · bump unreadCount Cache-->>Bell: badge re-renders Hook->>Toast: title + body + optional CTA
Folder shape
Section titled “Folder shape”- Notifications.constants.ts
- Notifications.list.queries.tsfeed and unread count reads
- Notifications.mutations.tsoptimistic mark-read/archive operations
- Notifications.preferences.queries.tspreference grid reads and writes
- Notifications.cache.tscache merge and rollback helpers
- Notifications.utils.ts
- Notifications.stream-utils.tsdefensive SSE parsing
- Notifications.types.ts
- useNotificationStream.tsmounted once from AppShell
- components/
- NotificationBell/
- NotificationCenterPopover/
- NotificationListItem/
- NotificationsPage/
- NotificationsPreferencesPage/
- PreferenceRow/
- PreferenceCell/
Same anatomy as features/dashboard/ (queries + utils at the feature root, components in components/<Name>/ with the 8-file layout). The query surface is split across three files (reads, mutations, preferences) to keep each file under the max-hooks-per-file threshold enforced by @boring-stack-pkg/eslint-plugin-react-component-architecture.
Queries
Section titled “Queries”The full hook surface, grouped by file:
useNotificationsList(status?: "unread" | "read" | "archived")useUnreadNotificationCount()
// Notifications.mutations.tsuseMarkNotificationRead() // optimisticuseArchiveNotification() // optimisticuseMarkAllNotificationsRead() // optimistic
// Notifications.preferences.queries.tsuseNotificationPreferences()useUpdateNotificationPreferences()useUnreadNotificationCount reads from the list cache when present and falls back to a server query otherwise. Every mutation snapshots the cache, applies the optimistic write via the helpers in Notifications.cache.ts, and rolls back if the request fails. onSettled invalidates both list and unread-count keys so the UI reconciles with the server.
Realtime
Section titled “Realtime”useNotificationStream is mounted once in AppShell.hooks.ts. It opens a credentialed EventSource against ${VITE_API_URL}/api/v1/notifications/stream only when capabilities.features.notifications.sse === true (from GET /api/v1/capabilities/). When SSE is disabled on the API (NOTIFICATIONS_SSE_ENABLED=false, the default), notifications still work through the paginated feed and mutations — just without live push.
mergeStreamNotificationIntoCache(qc, notification);toast(notification.title, { description: notification.body, action: notification.ctaUrl !== null ? { label: notification.ctaLabel ?? t("notifications.openCta"), onClick: () => { void navigate(notification.ctaUrl); } } : undefined});Defensive parse: malformed JSON or messages with the wrong shape are dropped with a warn log, never thrown.
Routes
Section titled “Routes”| Path | Component |
|---|---|
/notifications | NotificationsPage |
/notifications/preferences | NotificationsPreferencesPage |
Both wrap inside <AppShell>, which holds the header, bell, logout, and the SSE hook.
Adding a new notification UI
Section titled “Adding a new notification UI”You don’t. The backend ships pre-rendered strings; the UI is event-agnostic by design. To add a new event type, the API defines it (see API notifications) and the bell, page, and toast pick it up with no UI change.
If you ever need a per-event-type visual treatment (badge colour, icon), branch on notification.eventType inside NotificationListItem only. Don’t fork the page.
Web Push (v1.1)
Section titled “Web Push (v1.1)”Browser push notifications via the W3C Push API + VAPID. The whole flow
lives in useWebPush.hooks.ts (under src/hooks/) plus a small service
worker at public/sw.js. The Settings page renders a state-aware
“Browser notifications” card that wraps the hook.
Setup
Generate VAPID keys on the API (bun run vapid:generate) and paste
the public key into the UI as VITE_VAPID_PUBLIC_KEY. Without it,
the Settings card renders “Web Push is not configured for this
deploy.”
Service worker
public/sw.js is copied to the dist root by Vite (no plugin needed)
so the scope is /. Two handlers: push calls
showNotification(title, { body, data: { url } });
notificationclick focuses the matching tab if open, otherwise
opens the URL. Registered once from src/app/main.tsx (gated on
'serviceWorker' in navigator).
useWebPush contract
Returns { isSupported, isConfigured, permission, isSubscribed, isPending, subscribe, unsubscribe }. The Settings card maps that
state machine into copy: unsupported / not-configured / blocked /
not-subscribed / subscribed. Lives in src/hooks/ because the
feature accounts consumes it without owning it.
Preference grid integration
web-push appears alongside in-app and email in
PREFERENCE_CHANNEL_COLUMNS. Toggling it follows the same pattern as
other channels: the backend dispatcher reads
notification_preference rows for (userId, eventType, channel).
Out of scope (v1)
Section titled “Out of scope (v1)”Cross-tab sync via BroadcastChannel
Each tab holds its own SSE connection; the badge converges via cache invalidation.
Notification grouping
Backend doesn’t roll up “3 people liked your post” yet; UI doesn’t either.
Per-event-type custom rendering
Backend ships pre-rendered strings; UI stays event-agnostic.
Related
Section titled “Related”- API notifications: the dispatcher, channels, and SSE source.
- OpenAPI client: how the typed client surfaces the notifications endpoints.
- Component anatomy: the 8-file layout these components follow.