better-push
Build notifications

In-app feed

A queryable, paginated in-app notification feed with unread counts and read state.

Every push.notify(...) writes a bp_notification row. The in-app feed exposes eligible rows as a paginated list with unread counts and read state, plus headless React components to render it.

A notification is in the feed for its user when it has an inApp delivery with status delivered. Suppressed and push-only notifications are excluded (but still recorded for audit). See Preferences for how a notification becomes suppressed.

Endpoints

All routes are added to the existing catch-all router - no change to your Next.js or TanStack Start handler is needed. They require a session and use the same JSON error envelope as the other mounted endpoints.

Method & pathPurpose
GET {basePath}/notificationsPaginated feed + unreadCount
GET {basePath}/notifications/unread-count{ count }
POST {basePath}/notifications/:id/readMark one read (idempotent)
POST {basePath}/notifications/read-allMark all unread read

GET /notifications takes limit (default 20, max 50) and an opaque cursor for keyset pagination (newest first). Its response:

{
  "notifications": [
    { "id": "...", "type": "orderShipped", "title": "...", "body": "...",
      "data": {}, "readAt": null, "createdAt": "..." }
  ],
  "nextCursor": "opaque-or-null",
  "unreadCount": 3
}

nextCursor is null on the last page; an invalid cursor returns 400.

useNotificationFeed

app/inbox.tsx
"use client";

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

export function Inbox() {
  const {
    notifications,
    unreadCount,
    isLoading,
    hasMore,
    loadMore,
    markRead,
    markAllRead,
  } = useNotificationFeed({ pageSize: 20, pollInterval: 30000 });

  if (isLoading) return <p>Loading…</p>;
  return (
    <div>
      <button onClick={() => markAllRead()}>Mark all read ({unreadCount})</button>
      <ul>
        {notifications.map((n) => (
          <li key={n.id} onClick={() => markRead(n.id)}>
            {n.title}
          </li>
        ))}
      </ul>
      {hasMore && <button onClick={() => loadMore()}>Load more</button>}
    </div>
  );
}

markRead and markAllRead update local state optimistically. The hook is visibility-aware: polling pauses when the tab is hidden and refreshes on re-show, backing off on repeated network errors.

Clearing the badge when a push is tapped

By default, tapping a push notification does not mark it read: the unread count stays where it was until the user opens the item in the feed. Opt in with one flag:

const feed = useNotificationFeed({ markReadOnPushClick: true });

With it on, clicking a push notification marks that notification read wherever the user is signed in - tapping on a phone clears the badge in the browser too.

It works because every provider now ships the notificationId alongside the payload, and the service worker passes it to the page on click (it does not call the API itself: a worker fetch would only carry cookie sessions, not bearer tokens). If no tab is open, the worker appends ?bp_read=<id> to the URL it opens and the hook consumes it. The parameter is always stripped from the address bar, whether or not the option is on.

A tap is not always a read

Off by default on purpose. In a chat app, "read" usually means the thread was opened and seen, not that a banner was dismissed into the app. Turn it on only where opening the push really is the acknowledgement.

React Native gets the same option from the same hook: useNotificationFeed({ markReadOnPushClick: true }) in @better-push/core/native wires both an expo-notifications response listener and the tap that cold-started the app.

Transport

By default the hook does not simply poll. It opens the server's SSE stream at GET {basePath}/events and refetches the moment something changes, keeping a slow safety poll behind it. Where streaming does not work - a serverless host, a buffering proxy, React Native - it falls back permanently to polling on its own, and says so:

const feed = useNotificationFeed();
feed.transport; // "sse" while a stream is live, "polling" otherwise
OptionDefaultMeaning
pollInterval30000Poll interval when no stream is live
idlePollInterval300000Safety poll interval while a stream is live
transport"auto""polling" never opens a stream; "sse" never falls back

Nothing about your component changes either way - the hook returns the same shape it always did. With more than one app instance, cross-instance signals need cache: redis(...); see Realtime Feed for the whole picture.

Headless components

The styled copy-in inbox shows the feed states and interactions without making requests from this page:

Interactive styled notification inbox with populated, empty, loading, and error states.

@better-push/core/react ships headless components: minimal semantic markup, no design system, fully styleable via className and render-prop props. They work identically on Next.js and TanStack Start.

app/header.tsx
"use client";

import { useState } from "react";
import {
  NotificationBell,
  NotificationInbox,
  useNotificationFeed,
} from "@better-push/core/react";

export function Header() {
  const feed = useNotificationFeed();
  const [open, setOpen] = useState(false);
  return (
    <div style={{ position: "relative" }}>
      <NotificationBell feed={feed} open={open} onOpenChange={setOpen} />
      {open && <NotificationInbox feed={feed} />}
    </div>
  );
}
  • NotificationBell - an icon slot plus an unread badge. Props: onClick, renderBadge, className, children (custom icon), and an open / onOpenChange passthrough.
  • NotificationInbox - the list: each item shows title, body, relative time, and an unread indicator; clicking marks it read and, if data.url is set, links there. Includes "mark all read" and a "load more" trigger. Props: renderItem, emptyState, className.

Passing a shared feed (as above) makes the bell and inbox use one hook so a read in the inbox updates the badge immediately. Omit it and each component manages its own feed. Every component renders with zero props too.

Hook result

Prop

Type

On this page