better-push
Build notifications

Typed notifications

Declare notification types once with define() and get type-checked notify() calls.

Typed notification definitions let you declare each notification type once. notify() then derives the payload type, title, body, and the preferences UI from that single declaration. The lightweight ad-hoc form stays available for one-off sends.

define and the notifications config key

Wrap each type with define<Payload> and pass a notifications map to betterPush:

src/push.ts
import { betterPush, define } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { db } from "@/db";

export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [webPush({ vapid: { /* ... */ } })],
  notifications: {
    orderShipped: define<{ orderId: string; eta: string }>({
      title: (p) => `Order ${p.orderId} shipped`,
      body: (p) => `Arriving ${p.eta}`,
      data: (p) => ({ url: `/orders/${p.orderId}` }),
      label: "Order shipped",
      group: "Orders",
    }),
    newComment: define<{ postId: string; author: string }>({
      title: (p) => `New comment from ${p.author}`,
      label: "New comment",
      group: "Social",
    }),
  },
  session: async (request) => {
    /* ... */
  },
});

betterPush captures the notifications map in its type, so the returned push.notify is checked against your definitions.

Definition fields

FieldMeaning
titleStatic string, or (payload) => string. Required.
bodyStatic string, or (payload) => string | undefined. Optional.
channelsChannels this type targets. Default ["push", "inApp"].
data(payload) => Record<string, unknown>, deep-merged under args.data.
labelHuman label for the preferences UI. Defaults to the type key.
groupOptional grouping bucket in the preferences UI.
defaultEnabledFallback when no preference row exists. Default true.

Invalid definitions throw a BetterPushError at construction naming the type: every definition needs a title; channels, if present, is a non-empty subset of ["push", "inApp"]; and a type key may not be "*" (reserved for wildcard preferences).

The two notify forms

Typed (primary)

await push.notify("orderShipped", {
  userId: user.id,
  payload: { orderId: "1024", eta: "tomorrow" },
  // channels?: override the definition's channels
  // data?: merged over definition.data(payload)
});

The payload is inferred from define<Payload>. A wrong payload shape is a compile error; an unknown type key is a compile error too. When a definition's Payload is void, payload is optional.

Ad-hoc (one-off sends)

await push.notify({
  userId: user.id,
  title: "Something happened",
  body: "No declared type needed.",
  data: { url: "/" },
  // type?: defaults to "default"; channels?: defaults to both
});

An app that never declares a type can use only this form - notifications is optional. If an ad-hoc type happens to match a registered definition, that definition's defaultEnabled and label still apply for preference resolution.

Resolution

For the typed form, better-push computes:

  • channels = args.channels ?? definition.channels ?? ["push", "inApp"]
  • title = typeof def.title === "function" ? def.title(payload) : def.title
  • body likewise
  • data = { ...def.data?.(payload), ...args.data }

The result flows into the normal send pipeline and is gated by preferences.

Root exports

define, NotificationDefinition, and NotificationDefinitions are exported from the package root (@better-push/core).

On this page