React SDK
@notificationstream/react: provider, hooks, bell, drawer and full-page inbox components for React.
React components and hooks: a bell with a popover drawer, a side-sheet drawer, a full-page inbox and headless hooks. Built on the JavaScript SDK, which handles polling, realtime updates, caching, multi-tab dedupe and optimistic updates. The components render its headless UI core and its stylesheet.
- React 18+ (including 19). SSR and RSC safe: the built files start with
"use client"and nothing is fetched on the server. - Themable from the dashboard, from code (
theme) or with CSS variables. classNamesper slot, anunstyledmode, render props andlabels.- Accessible:
aria-expanded/aria-haspopup, dialog roles, tabs with arrow keys, focus trap, Esc and outside click to close.
Install
pnpm add @notificationstream/reactQuick start
import {
NotificationBell,
NotificationProvider,
usePolling,
useRealtime,
} from "@notificationstream/react";
export function AppShell({ user, children }) {
return (
<NotificationProvider
publishableKey="pk_…"
user={{ id: user.id, name: user.name, email: user.email }}
token={getNotificationToken} // fetches a user token from your server
>
<LiveUpdates />
<header>
<NotificationBell />
</header>
{children}
</NotificationProvider>
);
}
// Optional: update as soon as the feed changes (Growth plan and up), and poll every
// minute while realtime isn't connected. Without either, the feed refreshes when the
// page becomes visible again.
function LiveUpdates() {
useRealtime();
usePolling(60_000);
return null;
}getNotificationToken calls your token endpoint, for example
() => fetch("/api/notifications/token").then((r) => r.text()); sign the token there with
the Node SDK. A function lets the client fetch a new
token when the old one expires.
In the Next.js App Router every export is already marked "use client", so you can render
the provider from a client component or directly in a server layout. Props passed from a
server component must be serializable, so a server layout can pass a token string but not
a token function (or callbacks such as onNotificationClick). A token string stops working
when it expires, so a page that stays open needs the provider in a client component with
a token function.
<NotificationProvider>
| Prop | Type | |
|---|---|---|
publishableKey | string | Required unless client is passed. |
user | ClientUser | Required unless client is passed. { id, name?, email?, avatarUrl?, locale?, timezone?, attributes? }; the fields other than id are synced to the project user. Changing it (compared by value) switches user without remounting. |
token | string | () => string | Promise<string> | Required unless client is passed. A user token or a function that fetches one. A new string re-identifies; a new function on each render doesn't. |
apiUrl | string | Default https://api.notificationstream.com. |
locale | string | Preferred content locale. |
theme | ThemeConfig | Merged over the dashboard theme. |
labels | Partial<NotificationLabels> | UI strings. |
client | NotificationClient | An existing client from createNotificationClient (install @notificationstream/js to import it). The provider won't destroy it on unmount. |
injectStyles | boolean | Default true: injects one <style id="notificationstream-styles"> on mount. |
pageSize, fetch, WebSocket, storage, platform, multiTab, deliverOnStart | Passed through to createNotificationClient. |
Changing publishableKey, apiUrl, locale or pageSize creates a new client. The other
client options are read once, when the provider mounts.
Hooks
useNotifications(): NotificationState & NotificationActions & { client }
useUnreadCount(): number // unread, excluding archived
useUnseenCount(): number // badge count
useNotificationClient(): NotificationClient
useNotificationSelector<T>(selector: (state: NotificationState) => T, isEqual?): T // re-renders only when the selected value changes
usePresentedNotification({ filter?, enabled? }): { notification: FeedItem | null; dismiss({ markAsRead? }?) }
useNotificationArrival(listener: (item: FeedItem) => unknown, { filter?, enabled? }): void
useRealtime(enabled = true): void // keeps a realtime connection open while mounted
usePolling(interval: number | null): void // polls every `interval` ms (at least 10 s) while mounteduseRealtime and usePolling work anywhere under the provider and hold their request
while mounted (see realtime() and poll()). Polling
pauses while the realtime connection is open, so using both makes polling the fallback.
useNotifications().realtime is "off", "connecting" or "open". See
Polling, realtime and performance.
usePresentedNotification and useNotificationArrival present newly arrived notifications
as a popup, bottom sheet or page of your own; see
Presenting new notifications.
NotificationActions: refresh, loadMore, setFilter({ status?, category? }),
markAsRead(ids), markAsUnread(ids), archive(ids), unarchive(ids),
markAllAsRead(), markAllAsSeen(), trackClick(id). See the
state and methods of the client.
A headless inbox:
import { useNotifications } from "@notificationstream/react";
function Inbox() {
const { items, status, hasMore, loadMore, markAsRead } = useNotifications();
if (status === "loading") return <Spinner />;
return (
<ul>
{items.map((n) => (
<li key={n.id} onClick={() => markAsRead(n.id)}>
{n.title}
</li>
))}
{hasMore && <button onClick={loadMore}>More</button>}
</ul>
);
}A custom badge that only re-renders when the count changes:
const unseen = useNotificationSelector((s) => s.unseenCount);Components
All components must be inside <NotificationProvider>. The bell, drawer and feed accept
these props (NotificationItem takes a subset, see below):
| Prop | Type | |
|---|---|---|
theme | ThemeConfig | Component-level override (highest priority). |
labels | Partial<NotificationLabels> | |
classNames | Partial<Record<NotificationSlot, string>> | Extra class per slot (see Styling). |
unstyled | boolean | Drop all built-in ns-* classes. |
onNotificationClick | (item, event) => unknown | Runs after the click is tracked. Call event.preventDefault() or return false to skip navigating to actionUrl. Only URLs that pass isSafeUrl (http(s), mailto, relative) are navigated to. |
renderItem | (item, helpers) => ReactNode | helpers: onClick, markAsRead, markAsUnread, archive, unarchive. helpers.onClick() runs the full click flow: track the click, call onNotificationClick, then navigate. |
renderEmpty | (status) => ReactNode | status: "all" | "unread" | "archived". |
formatTime | (item, now) => string | Default: relative time in the item's locale. |
openLinksInNewTab | boolean | Default false (location.assign). |
showArchive | boolean | Default true. The archive button appears on hover or focus. |
The click event is { item, preventDefault(), defaultPrevented }.
<NotificationBell />
A bell button with an unseen-count badge that opens a drawer, rendered in a portal. Opening it marks everything as seen and clears the badge.
| Prop | Type | Default |
|---|---|---|
placement | "bottom-end" | "bottom-start" | "top-end" | "top-start" | bottom-end |
variant | "popover" | "sheet" | popover |
icon | ReactNode | built-in bell |
renderBadge | ({ unseenCount, unreadCount }) => ReactNode | |
maxBadgeCount | number | 99 (renders "99+") |
open / defaultOpen / onOpenChange | uncontrolled by default | |
tabs | FeedStatus[] | ["all", "unread"] |
categories, side, container | see NotificationDrawer | |
className | string | on the bell's wrapper |
<NotificationDrawer />
A controlled drawer (open, onOpenChange). variant="sheet" (the default) is a modal
side panel (side="right" | "left") with an overlay; variant="popover" is positioned
against anchorRef. Other props: placement, tabs (default All/Unread), categories,
container (portal target), className. Esc and outside click close it; focus moves in,
is trapped, and returns to the anchor on close.
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Inbox</button>
<NotificationDrawer open={open} onOpenChange={setOpen} variant="sheet" /><NotificationFeed />
A full-page inbox: All / Unread / Archived tabs, an optional category filter, infinite
scroll (IntersectionObserver, with a "Load more" button as fallback) and "Mark all as
read". categories takes strings or { value, label } objects.
<NotificationFeed
categories={["billing", { value: "comments", label: "Comments" }]}
onNotificationClick={(item, event) => {
event.preventDefault();
router.push(item.actionUrl ?? "/");
}}
/><NotificationItem item={…} />
A single row (<li>): avatar (imageUrl or initial), bold title while unread, unread dot
and background, Markdown-subset body rendered as React nodes (never HTML), relative time, an
action button for actionLabel, and read/unread and archive buttons on hover. Props:
item, labels, classNames, unstyled, onNotificationClick, formatTime,
openLinksInNewTab, showArchive, plus now and showReadToggle (default true). It
applies no theme itself: render it inside an element with the ns-root class so the
--ns-* variables reach it.
Styling
Styles are injected automatically. They are the shared web stylesheet of
@notificationstream/js (see Styles):
@notificationstream/react/styles.css is the same file as
@notificationstream/js/styles.css, and styles/injectStyles are re-exported from
@notificationstream/js/styles. To ship them through your own CSS pipeline instead:
import "@notificationstream/react/styles.css";
<NotificationProvider injectStyles={false} /* … */ />;Colors come from CSS variables on .ns-root, so you can restyle with CSS alone. Injected
styles land at the end of <head>, after your CSS, and win over rules with the same
selector, so load the stylesheet yourself as above and put your overrides after it:
.ns-root {
--ns-primary: #7c3aed;
--ns-radius: 8px;
}
.ns-root[data-color-scheme="dark"] {
--ns-background: #0b0b0f;
}Variables: --ns-primary, --ns-radius, --ns-font-family, --ns-background,
--ns-foreground, --ns-muted, --ns-muted-foreground, --ns-border,
--ns-unread-background, --ns-badge-background, --ns-badge-foreground, plus
--ns-panel-width (default 380px) and --ns-panel-max-height for the popover.
Theme precedence: built-in defaults → dashboard theme → the client's theme option →
provider theme → component theme. Values set explicitly (dashboard or code) are applied
as inline variables, so they win over the same variables in your CSS.
colorScheme: "system" follows prefers-color-scheme live. See
Theming.
classNames slots (the default class of slot fooBar is ns-foo-bar):
root, bell, icon, badge, popover, overlay, sheet, panel, header,
title, headerActions, markAllButton, closeButton, toolbar, tabs, tab,
categorySelect, list, item, itemUnread, itemMain, avatar, content,
itemTitle, itemBody, time, dot, actionButton, itemActions, iconButton,
empty, error, loading, loadMore, loadingMore.
In each row, itemMain lays out the avatar and content, and itemTitle is the row's
button: it stretches over the whole row, so body links and the action, read and archive
buttons are never nested inside a button.
Localization
<NotificationProvider
locale="de"
labels={{ title: "Benachrichtigungen", markAllAsRead: "Alle als gelesen markieren", unread: "Ungelesen", all: "Alle" }}
/* … */
/>Notification content is localized by the server (locale, then the project user's locale,
then the project default). Relative times use each item's own locale. All label keys are
listed on the JavaScript SDK page.
Note
The package re-exports the client types (FeedItem, NotificationState,
ThemeConfig, …) so you rarely need to import @notificationstream/js directly.