better-push
Build notifications

Preferences

Per-type, per-channel notification preferences with deterministic resolution and suppression.

Preferences let users turn a notification type—or a single channel—off. Subsequent notify() calls suppress disabled channels: the push is not sent and/or the item does not enter the in-app feed, while an auditable suppressed delivery row is still recorded.

Preferences are rows in your Postgres (bp_preference), keyed by (user_id, type, channel). Add the table to your schema:

src/db/schema.ts
export {
  bpDevice,
  bpNotification,
  bpDelivery,
  bpPreference,
} from "@better-push/core/adapters/drizzle/schema";

Resolution rule

For a (userId, type, channel), enabled is decided by the first match in this precedence order:

#Match
1stored row (type, channel) exact
2stored row (type, "*")
3stored row ("*", channel)
4stored row ("*", "*")
5the definition's defaultEnabled (if the type is registered)
6true (global default)

Resolution is per channel, computed from a single listPreferences(userId) read plus in-memory precedence - never a query per channel. "*" is the wildcard for "all types" or "all channels".

Endpoints

Added to the existing router; both require a session.

GET {basePath}/preferences

Returns one entry per registered notification type, with resolved per-channel state:

{
  "preferences": [
    { "type": "orderShipped", "label": "Order shipped", "group": "Orders",
      "channels": { "push": true, "inApp": true } },
    { "type": "newComment", "label": "New comment", "group": "Social",
      "channels": { "push": false, "inApp": true } }
  ]
}

The channels listed per type are the definition's channels. If notifications is empty, preferences is [].

PUT {basePath}/preferences

Bulk upsert on (user_id, type, channel):

{ "preferences": [ { "type": "newComment", "channel": "push", "enabled": false } ] }

Each type must be "*" or a registered type key; each channel must be "push", "inApp", or "*". An unknown type or channel returns 400. The response is the same shape as GET /preferences (the post-update resolved view), so the client can re-render without a second fetch.

usePreferences

app/settings/notifications.tsx
"use client";

import { usePreferences } from "@better-push/core/react";

export function Settings() {
  const { preferences, dirty, isSaving, setPreference, save } = usePreferences();
  return (
    <div>
      {preferences.map((entry) =>
        Object.entries(entry.channels).map(([channel, on]) => (
          <label key={entry.type + channel}>
            <input
              type="checkbox"
              checked={on}
              onChange={(e) => setPreference(entry.type, channel as "push" | "inApp", e.target.checked)}
            />
            {entry.label} · {channel}
          </label>
        )),
      )}
      <button disabled={!dirty || isSaving} onClick={() => save()}>
        {isSaving ? "Saving…" : "Save"}
      </button>
    </div>
  );
}

setPreference edits local state (and sets dirty); save PUTs only the changed entries and adopts the server's resolved view from the response. reset discards edits.

NotificationPreferences component

This styled copy-in version is interactive and uses fixture state, so changing a switch here does not save anything to a server:

Interactive grouped notification preferences with in-app and push switches.

The headless component renders usePreferences as grouped rows of per-channel toggles with a Save button:

"use client";
import { NotificationPreferences } from "@better-push/core/react";

export default function Page() {
  return <NotificationPreferences />;
}

Props: renderRow and className for styling, or pass a shared preferences hook result.

In React Native the same usePreferences() hook is available from @better-push/core/native, with the same return value; you render the toggles with RN primitives.

Suppression

When you call notify(), better-push resolves each requested channel:

  • inApp enabled → an inApp delivered delivery (the item enters the feed).
  • inApp disabled → an inApp suppressed delivery (excluded from the feed).
  • push enabled → active devices are loaded and sent through their provider.
  • push disabled → one push suppressed marker, and no device sends.

The bp_notification row is always written (it is the event record). NotifyResult.deliveries includes the suppressed entries so callers can see what happened.

Preference scope

Preferences are per-type, per-channel on/off switches. Quiet-hour windows and time-based suppression are not supported.

Hook result

Prop

Type

On this page