Notifications
In-app notifications with a live NotificationBell, plus Expo push notifications to registered mobile devices.
Notifications
Valcraven has a built-in per-user notification system: create a notification from any server code and it appears instantly in the user's NotificationBell (the header dropdown in the /app shell) over a WebSocket, and — if they have the mobile app installed — as an Expo push notification. Notifications persist to the database so they survive reloads and offline periods.
Where it lives
| Piece | Path |
|---|---|
| Notification helpers | apps/web/lib/notifications.ts |
| Header bell (UI) | apps/web/components/notification-bell.tsx |
| List / count API | apps/web/app/api/notifications/route.ts |
| Mark read / read-all API | apps/web/app/api/notifications/[id]/read, .../read-all |
| Push token registration | apps/web/app/api/push-tokens/route.ts |
| Tables | notifications, push_tokens |
Creating a notification
The one function you call is createNotification():
import { createNotification } from "@/lib/notifications";
await createNotification(
userId,
"success", // "info" | "success" | "warning" | "error"
"Payment received", // title
"Your invoice is paid.", // message (optional)
);A single call does three things:
- Persists a row to the
notificationstable (unread). - Publishes a
new-notificationevent to the realtime channelnotifications:<userId>so any open browser tab updates instantly. - Sends Expo push to every registered device token for that user (fire-and-forget).
The realtime publish is best-effort and never throws — if the realtime Worker is unavailable the notification still persists and the bell picks it up on its next load. See Realtime.
Don't confuse these with toast notifications.
useToast()(@/lib/use-toast) shows transient in-page toasts and does not persist.createNotification()is for durable, per-user notifications that belong in the bell.
The NotificationBell
components/notification-bell.tsx renders in the app-shell header. It:
- Loads the user's notifications and unread count on mount from
GET /api/notifications. - Subscribes to
notifications:<userId>via theuseRealtimehook; on anew-notificationevent it prepends the item and bumps the unread badge — no polling. - Shows an unread count badge (capped at
9+). - Lets the user mark a single item read or Mark all read.
Because it uses the typed @valcraven/core API client, the same list/mark-read calls work from the mobile app.
API
All routes require an authenticated session and are scoped to the current user.
| Method + path | Purpose |
|---|---|
GET /api/notifications?limit=&offset= | List notifications (newest first) + unreadCount |
POST /api/notifications/[id]/read | Mark one notification read |
POST /api/notifications/read-all | Mark all read; returns the count updated |
limit is clamped to 1–100 (default 50). The list response shape is { notifications, unreadCount }.
Push notifications (mobile)
Push targets the mobile app via Expo's push service. When a device registers, the app calls:
POST /api/push-tokens { token, platform: "ios" | "android" }
DELETE /api/push-tokens { token }Tokens are stored in push_tokens, keyed uniquely on the token (upsert on conflict), scoped to user_id. When you create a notification, lib/notifications.ts looks up all of the user's tokens and POSTs a batch to https://exp.host/--/api/v2/push/send with the title, body, and { type: "notification", notificationId } payload. This is fire-and-forget — a push failure is logged but never propagated, and users with no registered device simply get nothing.
No configuration is required for this to be a safe no-op: with zero registered tokens, the push step returns early. See the mobile app for the client side of token registration.
Data model
notifications columns of note: type (info/success/warning/error, default info), title, message, read (0/1), createdAt (unix epoch). push_tokens holds token, platform (default ios), and timestamps, with a unique index on token.
Extending
- New notification type styling — the four types map to bell styling; add server-side triggers by calling
createNotification()wherever the event happens (payment succeeded, item shared, etc.). - Broadcast to many users — loop over user ids and call
createNotification()for each; there's no built-in fan-out to "all users".
Related
- Realtime — the WebSocket channel that delivers live notifications
- Toasts:
useToast()from@/lib/use-toastfor transient in-page messages