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.
  • classNames per slot, an unstyled mode, render props and labels.
  • Accessible: aria-expanded/aria-haspopup, dialog roles, tabs with arrow keys, focus trap, Esc and outside click to close.

Install

pnpm add @notificationstream/react

Quick 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>

PropType
publishableKeystringRequired unless client is passed.
userClientUserRequired 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.
tokenstring | () => 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.
apiUrlstringDefault https://api.notificationstream.com.
localestringPreferred content locale.
themeThemeConfigMerged over the dashboard theme.
labelsPartial<NotificationLabels>UI strings.
clientNotificationClientAn existing client from createNotificationClient (install @notificationstream/js to import it). The provider won't destroy it on unmount.
injectStylesbooleanDefault true: injects one <style id="notificationstream-styles"> on mount.
pageSize, fetch, WebSocket, storage, platform, multiTab, deliverOnStartPassed 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 mounted

useRealtime 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):

PropType
themeThemeConfigComponent-level override (highest priority).
labelsPartial<NotificationLabels>
classNamesPartial<Record<NotificationSlot, string>>Extra class per slot (see Styling).
unstyledbooleanDrop all built-in ns-* classes.
onNotificationClick(item, event) => unknownRuns 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) => ReactNodehelpers: onClick, markAsRead, markAsUnread, archive, unarchive. helpers.onClick() runs the full click flow: track the click, call onNotificationClick, then navigate.
renderEmpty(status) => ReactNodestatus: "all" | "unread" | "archived".
formatTime(item, now) => stringDefault: relative time in the item's locale.
openLinksInNewTabbooleanDefault false (location.assign).
showArchivebooleanDefault 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.

PropTypeDefault
placement"bottom-end" | "bottom-start" | "top-end" | "top-start"bottom-end
variant"popover" | "sheet"popover
iconReactNodebuilt-in bell
renderBadge({ unseenCount, unreadCount }) => ReactNode
maxBadgeCountnumber99 (renders "99+")
open / defaultOpen / onOpenChangeuncontrolled by default
tabsFeedStatus[]["all", "unread"]
categories, side, containersee NotificationDrawer
classNamestringon 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.