JavaScript SDK

@notificationstream/js: a framework-agnostic notification client and headless UI core.

@notificationstream/js is a framework-agnostic client: a store you can subscribe to, with polling or realtime updates, caching, multi-tab dedupe and optimistic actions, plus a headless UI core for rendering your own bell and inbox. It has no runtime dependencies, is about 7 kB min+gzip for the client, and is SSR-safe (no DOM access at import time).

Using React or React Native? Use the React or React Native SDK. Both are built on this package: its client, its headless UI core and, for React, its stylesheet.

Install

pnpm add @notificationstream/js

Without a bundler

The package is plain JavaScript ES modules with no dependencies, so a page without a build step can load it from a CDN in a <script type="module">. Pin the version in production.

<script type="module">
  import { createNotificationClient } from "https://cdn.jsdelivr.net/npm/@notificationstream/js@0.1.0/+esm";

  const client = createNotificationClient({ /* the options from the quick start */ });
</script>

The stylesheet is available the same way, as https://cdn.jsdelivr.net/npm/@notificationstream/js@0.1.0/dist/styles.css.

Quick start

import { createNotificationClient } from "@notificationstream/js";

const client = createNotificationClient({
  publishableKey: "pk_…",
  user: { id: currentUser.id, name: currentUser.name, email: currentUser.email, locale: "en" },
  // A user token signed by your backend (see Identity verification).
  token: () => fetch("/api/notifications/token").then((r) => r.text()),
});

const unsubscribe = client.subscribe((state) => {
  renderBadge(state.unseenCount);
  renderList(state.items);
});

// Optional: update as soon as the feed changes (Growth plan and up)…
const stopRealtime = client.realtime();
// …and poll every minute while realtime isn't connected.
const stopPolling = client.poll(60_000);

await client.markAsRead("ntf_123");

Nothing is fetched until the first subscribe() or onNotification(), or a method that needs the feed (refresh(), loadMore(), setFilter() or an action). Polling stops when the last listener unsubscribes; the realtime connection closes a minute later.

createNotificationClient(options)

OptionTypeDefault
publishableKeystringrequiredProject publishable key (pk_…).
userClientUserrequired{ id, name?, email?, avatarUrl?, locale?, timezone?, attributes? }. id is your app's stable user id; the other fields are synced to the project user (see Profile sync).
tokenstring | () => string | Promise<string>requiredA user token for user.id, or a function that fetches one.
apiUrlstringhttps://api.notificationstream.com
localestringPreferred content locale. Falls back to the project user's locale, then the project default.
pageSizenumber201 to 50.
fetchtypeof fetchglobalThis.fetch
WebSockettypeof WebSocketglobalThis.WebSocketUsed by realtime().
storageStorageAdapter | nulllocalStorage if availablelocalStorage/AsyncStorage-compatible (getItem/setItem/removeItem, sync or async). null disables the cache.
platformPlatformAdapterbrowser adapter in a DOM, static elsewhereLifecycle events, storage, tab channel and leadership.
themeThemeConfigUsed by the bundled UIs, merged over the dashboard theme.
labelsPartial<NotificationLabels>UI strings for the bundled UIs.
multiTabbooleantrueShare one poller and realtime connection across tabs.
deliverOnStart"none" | "latest" | "unseen""none"Which notifications that already exist on start are handed to onNotification. See Presenting new notifications.

State

client.getState() returns an immutable snapshot; a new object is created on every change, which makes it useSyncExternalStore-friendly.

interface NotificationState {
  status: "idle" | "loading" | "ready" | "error";
  items: readonly FeedItem[];
  unreadCount: number; // unread, excluding archived, among the newest 100
  unseenCount: number; // badge count: published since the feed was last opened, among the newest 100
  hasMore: boolean;
  loadingMore: boolean;
  filter: { status: "all" | "unread" | "archived"; category: string | null };
  config: ClientConfig | null; // projectName, defaultLocale, theme (from the dashboard)
  error: NotificationClientError | null;
  userId: string;
  realtime: "off" | "connecting" | "open"; // the realtime connection, shared by all tabs
}

status is loading only while there is nothing to show. With cached data it is ready immediately and the cache is revalidated in the background. When a background refresh fails, status stays ready and error is set. status is error only when the first load failed.

When the project has no active subscription, the API answers 402 payment_required. The client then clears the feed and its cache, sets error.code to "payment_required" and stops polling until refresh() or identify() is called. status is ready, so the bundled UIs show the empty state.

When the server refuses the realtime connection because the plan doesn't include realtime or the origin isn't allowed, realtime stays off and error.code is "forbidden". The feed keeps working, and polling (if requested) keeps it fresh.

Each FeedItem has id, title, body, actionLabel, actionUrl, imageUrl, category, data, locale, publishedAt, expiresAt, readAt, seenAt and archivedAt (see Get the user's feed).

Methods

Method
subscribe(listener): () => voidStarts the client on the first call. Returns an unsubscribe function.
onNotification(listener, { filter? }): () => voidHands each newly arrived notification to one listener (return false to leave it queued). Starts the client like subscribe. See Presenting new notifications.
poll(interval): () => voidPolls every interval ms (at least 10 s) while something is subscribed and the page is visible. Paused while realtime is open. Several calls share one poller at the shortest interval. Returns a function that stops this request.
realtime(): () => voidKeeps a realtime connection open while something is subscribed and the page is visible. Returns a function that stops this request.
getState(): NotificationStateThe current snapshot.
refresh(): Promise<void>Refetch the first page of the current filter.
loadMore(): Promise<void>Fetch the next page (cursor-based).
setFilter({ status?, category? }): Promise<void>Switch tab or category; refetches.
markAsRead(ids) / markAsUnread(ids)ids: string | string[].
archive(ids) / unarchive(ids)Archived items leave the inbox and appear under status: "archived".
markAllAsRead()Marks everything read.
markAllAsSeen()Clears the badge (unseenCount). Call it when the feed is opened.
trackClick(id)Records a click and marks the notification read.
identify(user, token)Switch user: clears state and starts over with the new user and token. poll() and realtime() requests carry over.
destroy()Stops polling, closes the realtime connection and releases timers, listeners, the tab channel and the lock.
optionsThe options in use (reflects identify).

Actions update state immediately. If the request fails, the previous state is restored and state.error is set; action promises never reject. Requests that would change nothing are skipped (for example markAllAsSeen() when unseenCount is 0). Id lists are sent in batches of 100.

// Sign-out / account switch
client.identify({ id: nextUser.id, name: nextUser.name }, getNotificationToken);

User tokens

token can be a string or a function:

  • Function (recommended): the client calls it on start, again shortly before the token's exp and once after a 401, then retries the request. Use it for pages and apps that stay open longer than a token lives.
  • String: used as is. Once it expires, every request fails with 401 (error.code is "unauthorized") until you call identify() with a new token.

If the token's sub isn't user.id, the client reports an unauthorized error instead of showing another user's feed.

Profile sync

The fields of user other than id are written to the project user with POST /v1/client/profile, before the first feed request, but only when they differ from the last profile this device synced (the client remembers it with the feed cache). An unchanged profile costs no request. Tags can only be set from your backend.

Errors

NotificationClientError has:

Property
codeAn API error code ("unauthorized", "rate_limited", …) or "network_error"
statusHTTP status, 0 for network errors
detailserror.details from the API
retryAfterMilliseconds, when the server sent Retry-After

Polling behaviour

Every request sends the publishable key (X-Publishable-Key) and the user token (Authorization: Bearer …) as headers; no cookies are sent. Browsers send a CORS preflight for these headers, but cache the answer per URL (up to 2 hours in Chrome), and preflights don't count towards your plan's requests. Each poll counts as one request, whether or not the feed changed.

  • Interval: off unless you call poll(interval); the interval is raised to at least 10 seconds. Without it, the client fetches the first page on start and refetches on the events below or when you call refresh(). Polling pauses while realtime is open.
  • Visibility: polls only while the page is visible and at least one listener is subscribed. Focus, visibility and online events refetch if the last fetch is at least 10 seconds old.
  • Offline: pauses, and resumes as soon as the browser is back online.
  • Idle backoff: after 3 unchanged responses in a row the interval doubles, up to 5 minutes. Any change or interaction resets it.
  • Errors: exponential backoff with jitter, capped at 5 minutes; Retry-After is honoured. Without polling, only a failed first load is retried.
  • Tokens: with a token function, a new token is fetched shortly before exp, and a 401 fetches one and retries once.
  • Unchanged feeds: the browser's HTTP cache revalidates the feed on its own, so an unchanged feed comes back as an empty 304. It still counts as a request.
  • Request limit: once your organization has used its plan's monthly requests, each user's feed refreshes every 10 minutes; other polls get 429 (rate_limited) with Retry-After, and the client keeps showing the feed it has. See Over the limit.

Realtime

realtime() keeps a WebSocket to /v1/client/realtime open (Growth plan and up). The server only says that the feed changed; the client then fetches it with a normal feed request. See Polling, realtime and performance for what triggers an update.

  • Credentials: the publishable key and user token travel as WebSocket subprotocols, because browsers can't set headers on a WebSocket.
  • Visibility: connected while the page is visible and something is subscribed; it closes a minute after every tab was hidden.
  • Catch-up: after each (re)connect the client fetches the feed once, because nothing is replayed. Broadcast updates are spread over up to 30 seconds across users.
  • Reconnects: with backoff and jitter after a drop, a ping every minute to notice dead connections, and a fresh token from a token function before exp.
  • Refused: forbidden (plan or origin) stops realtime and sets error; payment_required behaves like the feed's; over the request limit it retries after 10 minutes.
  • Requests: the connection counts as one request towards your plan; messages and pings don't, the refetches they trigger do.

Multi-tab

Tabs with the same publishable key and user elect a leader through navigator.locks. Only the leader polls and holds the realtime connection, and it shares each result with the other tabs over BroadcastChannel. A newly opened tab asks the leader for its latest page instead of fetching. The leader keeps going while any tab is visible. Actions in one tab show up in all of them. When the leader closes, the next tab takes over; if a follower stops hearing from the leader, it polls on its own as a watchdog.

Without Web Locks every tab polls, but broadcast results reset the other tabs' timers, so visible tabs together still make about one request per interval.

Theming and localization helpers

Shared by every bundled UI, and useful for building your own:

Export
defaultThemeThe built-in theme (see Theming).
mergeTheme(...themes)Deep merge, later wins. Typical: mergeTheme(defaultTheme, state.config?.theme, myTheme).
resolveTheme(theme, systemDark)A complete ResolvedTheme for the active scheme; "system" follows systemDark.
themeToCssVariables(resolved)Every variable for a resolved theme: { "--ns-primary": …, "--ns-background": …, … }
themeConfigToCssVariables(theme, scheme)Only the variables a (partial) ThemeConfig sets for "light" or "dark", so stylesheet defaults and your own CSS still apply to the rest.
defaultLabels, resolveLabels(...overrides)UI strings, see below.
formatLabel(label, { count })Replaces {count}-style placeholders.
formatBadgeCount(n, max = 99)"99+" above max.
formatRelativeTime(timestamp, locale?, now?)Intl.RelativeTimeFormat: "5 minutes ago", "vor 2 Stunden".
parseInlineMarkdown(body)Tokens (text, bold, italic, link) for safe rendering of bodies. Never produces HTML. A backslash escape (\*) renders the character literally.
isSafeUrl(href)Allows only http(s), mailto and relative links.

NotificationLabels keys and defaults:

KeyDefault
titleNotifications
bellNotifications
unreadBadge{count} unread notifications
markAllAsReadMark all as read
markAsRead / markAsUnreadMark as read / Mark as unread
archive / unarchiveArchive / Unarchive
all / unread / archivedAll / Unread / Archived
allCategoriesAll categories
emptyYou're all caught up
emptyUnreadNo unread notifications
emptyArchivedNo archived notifications
error / retryCouldn't load notifications / Try again
loading / loadMoreLoading… / Load more
closeClose

Headless UI core

The behaviour behind every bundled UI (React and React Native) is exported from the main entry. It is pure or goes through the client, never touches the DOM at import time, and is tree-shakeable. Use it to build your own UI with the same semantics:

import {
  createFeedController,
  createItemModel,
  getNowTicker,
  handleNotificationClick,
  navigateTo,
} from "@notificationstream/js";

const feed = createFeedController(client);
const close = feed.open(); // drawer opened: clears the badge, and keeps it cleared while open

const ticker = getNowTicker(); // one shared 60 s clock for relative times
const render = () => {
  for (const item of client.getState().items) {
    const m = createItemModel(item, { now: ticker.now() });
    // m.unread, m.initial, m.time, m.timestamp, m.body (Markdown tokens),
    // m.showAction, m.readToggleLabel, m.archiveLabel
  }
};
const stopTicker = ticker.subscribe(render);

// On click: trackClick → onNotificationClick (can cancel) → isSafeUrl → navigate
const [first] = client.getState().items;
if (first) {
  handleNotificationClick(client, first, {
    onNotificationClick: (item, event) => {
      if (item.data?.internal) event.preventDefault(); // handle it with your router instead
    },
    navigate: (url) => navigateTo(url),
  });
}

Item view model

createItemModel(item: FeedItem, options?: NotificationItemModelOptions): NotificationItemModel

interface NotificationItemModelOptions {
  labels?: NotificationLabels; // default defaultLabels
  now?: number; // default Date.now()
  formatTime?: (item: FeedItem, now: number) => string;
  showReadToggle?: boolean; // default true
  showArchive?: boolean; // default true
}

interface NotificationItemModel {
  readonly item: FeedItem;
  readonly unread: boolean; // not read and not archived
  readonly archived: boolean;
  readonly initial: string; // avatar fallback letter
  readonly time: string; // formatTime(item, now), or relative time in the item's locale
  readonly timestamp: string; // publishedAt as ISO 8601, for <time dateTime>
  readonly body: readonly InlineToken[]; // body Markdown subset as tokens
  readonly showAction: boolean; // actionLabel and actionUrl are both set
  readonly readToggleLabel: string | null; // null hides it (archived, showReadToggle: false)
  readonly archiveLabel: string | null; // null hides it (showArchive: false)
}

isUnread(item): boolean and avatarInitial(title): string (first character, upper-cased; "•" for a blank title) are the same pieces on their own.

Click flow

handleNotificationClick(
  client: Pick<NotificationClient, "trackClick">,
  item: FeedItem,
  options: NotificationClickOptions<E>,
  event?: E, // default createNotificationClickEvent(item)
): string | null

interface NotificationClickOptions<E extends CancelableEvent> {
  onNotificationClick?: (item: FeedItem, event: E) => unknown;
  navigate: (url: string) => void; // location.assign, window.open, Linking.openURL…
  isSafeUrl?: (url: string) => boolean; // default isSafeUrl
}

It calls trackClick, then onNotificationClick, then navigate(actionUrl). Navigation is skipped when the callback returns false or calls event.preventDefault(), when there is no actionUrl, or when the URL fails isSafeUrl (by default only http(s), mailto and relative URLs pass; override it, for example, to allow app deep links). It returns the URL navigated to, or null.

Export
createNotificationClickEvent(item): NotificationClickEventThe { item, preventDefault(), defaultPrevented } event the React SDKs pass to onNotificationClick.
navigateTo(url, newTab = false): voidWeb navigation: location.assign, or window.open(url, "_blank", "noopener,noreferrer"). No-op without a window.

Feed controller

createFeedController(client): NotificationFeedController wraps the client with the drawer and inbox behaviour:

Method
open(): () => voidCall when the drawer opens: marks everything seen now, and again whenever new notifications arrive while it stays open. Returns the matching close.
markAllAsSeen()Clears the badge, only when unseenCount > 0.
setStatus(status)Switches tab via setFilter (no-op when already selected).
setCategory(category | null)Switches category.
syncTabs(tabs)Falls back to tabs[0] when the current status isn't one of the offered tabs.
loadMore(): booleanLoads the next page unless there is none or one is already loading. Returns whether it started.
refresh(), markAllAsRead()
toggleRead(item), toggleArchive(item)Read ↔ unread, archived ↔ unarchived.

Labels, phases and badge

Export
feedPhase(state): "loading" | "error" | "empty" | "items"What the list area shows. Items win; error only without items; idle counts as loading.
tabLabel(labels, status): stringTab text for all, unread or archived.
emptyLabel(labels, status): stringEmpty-state text for that tab.
tabForKey(tabs, current, key): FeedStatus | nullTab to select for ArrowLeft, ArrowRight (wrapping), Home and End, else null.
bellLabel(labels, unreadCount): stringThe bell's accessible name, e.g. "Notifications, 3 unread notifications".
badgeText(unseenCount, max = 99): string | nullBadge text ("99+" above max), or null to hide the badge.
mergeClientTheme(client, remoteTheme, ...overrides): ThemeConfigDashboard theme (state.config.theme) → client.options.theme → overrides. Pass the result to resolveTheme.
resolveClientLabels(client, ...overrides): NotificationLabelsDefaults → client.options.labels → overrides.

Clock and color scheme

Export
createNowTicker(interval = NOW_TICK_INTERVAL_MS): NowTickerA useSyncExternalStore-compatible clock: now() (stable between ticks) and subscribe(listener). It only ticks while subscribed.
getNowTicker(interval = NOW_TICK_INTERVAL_MS): NowTickerOne shared ticker per interval for every UI on the page.
NOW_TICK_INTERVAL_MS60000
getSystemDark(): booleanWhether prefers-color-scheme is dark; false without matchMedia (SSR, native).
subscribeSystemDark(listener): () => voidCalls listener when prefers-color-scheme changes.

Styles

@notificationstream/js/styles is the web stylesheet of the React components: ns-* class names, with every color, radius and font taken from --ns-* custom properties. It is a separate entry point, so React Native and server bundles never include it.

import { icons, injectStyles, styles } from "@notificationstream/js/styles";
// or, through your CSS pipeline:
import "@notificationstream/js/styles.css";
Export
styles: stringThe CSS, with the built-in defaults declared on .ns-root and .ns-root[data-color-scheme="dark"].
injectStyles(doc = document): voidAdds it once as <style id="notificationstream-styles">. Safe to call repeatedly; no-op during SSR.
iconsSVG path data (24×24 viewBox) of the bundled icons: bell, archive, unarchive, check, dot, close. Draw with stroke="currentColor", stroke-width="2" and round caps/joins; dot is filled instead.

@notificationstream/js/styles.css is the same stylesheet as a file. Restyle with plain CSS loaded after it, for example .ns-root { --ns-primary: #7c3aed; }.

Platform adapters

interface PlatformAdapter {
  isVisible(): boolean;
  isOnline(): boolean;
  subscribe(listener: (event: "visible" | "hidden" | "focus" | "online" | "offline") => void): () => void;
  storage?: StorageAdapter | null;
  createChannel?(name: string): TabChannel | null;
  acquireLeadership?(name: string, onLeader: () => void): (() => void) | null;
}
Adapter
createBrowserPlatform()Page Visibility, focus, online/offline, localStorage, BroadcastChannel, Web Locks
createStaticPlatform(storage?)Always visible; for Node, tests and workers
createDefaultPlatform()Picks one of the above

React Native provides an AppState-based adapter, createReactNativePlatform.

Note

Other exports: DEFAULT_API_URL, MIN_POLL_INTERVAL_MS (10000), MAX_POLL_INTERVAL_MS (300000), IDLE_POLLS_BEFORE_BACKOFF (3), RESUME_REFETCH_AFTER_MS (10000), REALTIME_HIDDEN_CLOSE_MS (60000), and the wire types (FeedItem, ClientConfig, ThemeConfig, …).