@onyx/notifications
Typed in-app and email notifications on the jobs queue, Durable Objects, and D1.
@onyx/notifications delivers typed notifications to organization members over
two channels: the in-app feed (with a live bell) and batched email digests.
notify() puts one message on the jobs queue and the platform handles fan-out,
per-user channel preferences, live delivery, and digest batching from there.
All of that wiring already exists; you send notifications and define new types.
Sending a notification
Call notify() from any server-side code (it is exported from
@onyx/notifications/client, but like enqueue in @onyx/jobs it is
server-only). The payload is typed from the notification's Zod schema. This is
how @onyx/auth announces a new member:
import { notify } from '@onyx/notifications/client';
await notify({
type: 'member-joined',
to: { organizationId: organization.id, except: [user.id] },
payload: {
memberName: user.name,
organizationName: organization.name,
},
});to always names an organization. Add users and/or roles to narrow the
audience and except to drop ids; put the acting user in except so nobody
is notified about their own action. Membership is resolved when the delivery
job runs, not when notify() is called, and the call costs one queue send
regardless of fan-out size.
Defining a notification type
Create a definition in src/notifications/ and list it in src/registry.ts.
notify(), delivery, preference filtering, and the settings UI all pick it up
automatically:
export const taskAssigned = defineNotification({
type: 'task-assigned',
label: 'A task is assigned to you',
schema: z.object({ taskTitle: z.string(), assignedBy: z.string() }),
defaultChannels: ['app', 'email'],
render: (payload) => ({
title: `${payload.assignedBy} assigned you "${payload.taskTitle}"`,
href: '/tasks',
}),
});render runs at delivery time and its result is denormalized into the feed
row, so historical rows keep rendering after the type changes or is removed.
defaultChannels applies when the recipient has no preference row; users
override channels per type on the /settings page.
Reading the registry
@onyx/notifications/registry is safe to import everywhere: it exposes the
type registry plus the NotificationType, NotificationPayload, and
NotificationChannel types. The settings UI and server functions in
apps/web read from it.
Behavior worth knowing
- In-app delivery is live and never delayed: open tabs see the bell update immediately.
- Email is batched: a burst of activity lands as one digest per user (sent
with
@onyx/mail). Quiet hours configured in/settingsdefer email digests only.
Configuration
| Name | Kind | Purpose |
|---|---|---|
NOTIFICATIONS_FLUSH_SECONDS | var, default 300 | Seconds an email waits in the outbox. Override in .dev.vars to test digests quickly. |
RESEND_API_KEY, MAIL_FROM | see @onyx/mail | Used to send digest emails. |