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.
layers/notifications/server/trpc/notifications.ts
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 to localStorage.
  • 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:

layers/notifications/server/trpc/admin-notifications.ts
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.
Nuxfire Production Kit

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.

© 2026 Nuxfire