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 & path | Purpose |
|---|---|
GET {basePath}/notifications | Paginated feed + unreadCount |
GET {basePath}/notifications/unread-count | { count } |
POST {basePath}/notifications/:id/read | Mark one read (idempotent) |
POST {basePath}/notifications/read-all | Mark 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
"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| Option | Default | Meaning |
|---|---|---|
pollInterval | 30000 | Poll interval when no stream is live |
idlePollInterval | 300000 | Safety 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:
@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.
"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 anopen/onOpenChangepassthrough.NotificationInbox- the list: each item shows title, body, relative time, and an unread indicator; clicking marks it read and, ifdata.urlis 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