In-app notifications persist as rows in the notifications table and stream to the user's personal channel in realtime via SSE. Facteur (@facteurjs/adonisjs) is the framework: one Notification<User, Params> class per type, with a deliverBy: { database, transmit } config and per-channel methods (asDatabaseMessage, asTransmitMessage). Transmit (@adonisjs/transmit) is the SSE transport — in-memory / single-node by default (transport: null in config/transmit.ts); swap to a distributed transport (e.g. Redis) when scaling out to multiple instances. Emission always goes through domain events: actions emit, a listener calls facteur.notification(...).params(...).to([user]).send().
Rules
- A dedicated
notificationsmodule owns the model, transformer, controller and routes for the bell:GET /notifications→ list + unseen count.POST /notifications/:id/read→ mark one read.POST /notifications/seen→ mark all as seen (the dot disappears; row stays "unread").POST /notifications/read→ mark all read.
- Notification classes live at
app/<mod>/notifications/<name>_notification.ts, extendingNotification<User, Params>:static options = { name: 'user-welcome', deliverBy: { database: true, transmit: true } } asDatabaseMessage() { return DatabaseMessage.create().setType(...).setContent({...}).setTags([...]) } asTransmitMessage() { return TransmitMessage.create().setContent({ kind: '...' }) } - User routing via
User.notificationTargets():
Facteur maps each channel's target automatically.{ database: { notifiableId: String(this.id) }, transmit: { channel: `notifications/user-${this.id}` }, } - Emission goes through events — see [[actions-events]]. The listener calls:
await facteur.notification(SomeNotification).params({...}).to([user]).send() - Shared prop
unseenNotifications(count) is computed in the Inertia middleware when a user is authenticated. It powers the bell badge on first render — the SSE stream keeps it in sync afterward. - Bell UI subscribes to the per-user channel via
useNotificationsChannel(user.id, onIncoming), hits the endpoint to fetch the list when opened, marks-all-seen on open, marks-read on click. - Frontend SSE client is a lazy singleton: one
EventSourceon/__transmit/events, all channels multiplex through it. - SSE transport —
apps/web/config/transmit.tsdefaults totransport: null(in-memory, single-node). The@adonisjs/transmitprovider auto-registers the/__transmit/*routes, so the bell works out of the box. Swaptransportfor a distributed driver (Redis) when horizontally scaling.
Doc refs
- Facteur — https://github.com/facteurjs/facteur
- AdonisJS Transmit — https://docs.adonisjs.com/guides/digging-deeper/transmit
- SSE spec (MDN) — https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
Workflow
Add a new notification type
- Class at
app/<mod>/notifications/<name>_notification.ts:import { DatabaseMessage } from '@facteurjs/adonisjs/channels/database' import { TransmitMessage } from '@facteurjs/adonisjs/channels/transmit' import { Notification } from '@facteurjs/adonisjs/types' export interface OrderShippedParams { orderId: number; trackingUrl: string } export default class OrderShippedNotification extends Notification<User, OrderShippedParams> { static options = { name: 'order-shipped', deliverBy: { database: true, transmit: true } } asDatabaseMessage() { const { orderId, trackingUrl } = this.params return DatabaseMessage.create() .setType('order-shipped') .setContent({ title: 'Your order shipped', body: `Order #${orderId}`, link: trackingUrl }) .setTags(['order', 'shipping']) } asTransmitMessage() { return TransmitMessage.create().setContent({ kind: 'order-shipped' }) } } - Emit via event. The action does:
emitter.emit('order:shipped', { user, orderId, trackingUrl }) - Listener in
<mod>/start/events.ts:emitter.on('order:shipped', async ({ user, orderId, trackingUrl }) => { await facteur.notification(OrderShippedNotification) .params({ orderId, trackingUrl }) .to([user]) .send() }) - Frontend rendering reads
content.title/content.body/content.linkfrom the persisted row — the bell renders every notification uniformly.kindon the transmit message is available for client-side filtering by type if needed. - Verify: a functional spec that emits the event and asserts a notification row persisted for the user. Don't fake the emitter in a listener spec — see [[testing]].
Custom SSE channel (not a Facteur notification)
For resource-scoped realtime (job progress, live status) that isn't a per-user Facteur notification — hardcoded channel pattern, transmit.authorize, broadcast from a listener → references/custom-sse-channel.md.
Testing
- Endpoint test: insert rows directly via
Notification.create({...})and hit the 4 endpoints. - End-to-end listener test:
mail.fake()first (to keep any co-fired mail from actually sending), thenawait emitter.emit('event:name', payload), then assert theNotificationrow exists.
See [[testing]].
Anti-patterns
- ❌ Calling
facteur.notification(...).send()directly from an action — emit an event; the listener sends. - ❌ Hardcoding
notifications/user-<id>in components — use the per-user subscription hook. - ❌ Storing render logic in the transmit message — the DB row is the durable copy; transmit is just a nudge (
{ kind: '...' }is enough). - ❌ Sending massive payloads over transmit — the browser refetches the list on notification; keep transmit small.
Related skills
[[actions-events]] · [[routes]] · [[mail]] · [[layout-shells]] · [[testing]] · [[authorization]]