Private packages

@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 /settings defer email digests only.

Configuration

NameKindPurpose
NOTIFICATIONS_FLUSH_SECONDSvar, default 300Seconds an email waits in the outbox. Override in .dev.vars to test digests quickly.
RESEND_API_KEY, MAIL_FROMsee @onyx/mailUsed to send digest emails.

On this page