Notifications
Real-time in-app notifications, delivered over WebSockets and backed by a Pinia store on the client.
What's Included
- Real-time notifications delivered over WebSockets
- Per-user enable/disable preference, persisted in
localStorage - Unread count tracking
- A notification slideover UI
Architecture
Backend
The notifications API is the tRPC router at layers/notifications/server/trpc/notifications.ts:
list— this user's notifications, newest first.create— inserts a notification, then immediately publishes it over WebSockets in the same call.updateReadStatus— marks a batch of notification ids read.
create: protectedProcedure
.input(z.object({ title: z.string(), message: z.string() }))
.mutation(async ({ ctx: { user, sendWebsocketsEvent }, input }) => {
const [notification] = await useDB().insert(notifications).values({ ...input, userId: user.id }).returning();
await sendWebsocketsEvent(user.id, { type: "notification", payload: notification });
return notification;
}),
See WebSockets for how sendWebsocketsEvent reaches the browser.
Frontend state
useNotificationsStore (layers/notifications/app/composables/use-notifications-store.ts) is a Pinia store:
- Holds notifications keyed by id, plus a running unread count.
initializeNotificationSettings()/setNotificationsEnabled()persist the on/off preference tolocalStorage.fetchAllNotifications(),markAsRead(),createNotification()wrap the tRPC calls above.
Real-time updates
layers/notifications/app/plugins/ws-notifications.client.ts subscribes to the current user's own topic through the WebSocket plugin; incoming events are pushed into the store, and toasts surface them when the preference is enabled.
Components
NotificationsSlideover.vue— the notification list UI, mounted in the team dashboard header's bell icon.
Sending to another user: the admin demo
notifications.create above only ever notifies the calling user — there's no targetUserId, because
nothing decides who's allowed to notify whom yet (that needs a real cross-tenant role, which this starter
kit doesn't model). What it does ship, as a demonstration of the same real-time pipeline aimed at someone
else: /admin/notifications (see Platform Admin Console),
a platform-admin-only page that picks a team, then a member of that team, and sends them a live
notification via a dedicated procedure:
send: platformAdminProcedure
.input(z.object({ targetUserId: z.string(), title: z.string(), message: z.string() }))
.mutation(async ({ ctx: { sendWebsocketsEvent }, input }) => {
const [notification] = await useDB().insert(notifications)
.values({ title: input.title, message: input.message, userId: input.targetUserId })
.returning();
await sendWebsocketsEvent(input.targetUserId, { type: "notification", payload: notification });
return notification;
}),
Same table, same sendWebsocketsEvent call as the tenant router — the only difference is userId comes
from the input instead of the caller's own session, and the procedure sits behind platformAdminProcedure
instead of protectedProcedure. A real "team owner notifies their members" feature would reuse this exact
shape once the app has a role that's allowed to call it.
Settings
The preference is exposed in two places, both reading and writing the same store state:
layers/notifications/app/pages/account-settings/notifications.vue— account-level.layers/notifications/app/pages/[[team]]/settings/notifications.vue— team-level.
Ready to build and launch your SaaS?
Get 100% full source code ownership, zero proprietary wrappers, and architecture engineered for millions of requests on Cloudflare.