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/jsWithout 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)
| Option | Type | Default | |
|---|---|---|---|
publishableKey | string | required | Project publishable key (pk_…). |
user | ClientUser | required | { 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). |
token | string | () => string | Promise<string> | required | A user token for user.id, or a function that fetches one. |
apiUrl | string | https://api.notificationstream.com | |
locale | string | Preferred content locale. Falls back to the project user's locale, then the project default. | |
pageSize | number | 20 | 1 to 50. |
fetch | typeof fetch | globalThis.fetch | |
WebSocket | typeof WebSocket | globalThis.WebSocket | Used by realtime(). |
storage | StorageAdapter | null | localStorage if available | localStorage/AsyncStorage-compatible (getItem/setItem/removeItem, sync or async). null disables the cache. |
platform | PlatformAdapter | browser adapter in a DOM, static elsewhere | Lifecycle events, storage, tab channel and leadership. |
theme | ThemeConfig | Used by the bundled UIs, merged over the dashboard theme. | |
labels | Partial<NotificationLabels> | UI strings for the bundled UIs. | |
multiTab | boolean | true | Share 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): () => void | Starts the client on the first call. Returns an unsubscribe function. |
onNotification(listener, { filter? }): () => void | Hands each newly arrived notification to one listener (return false to leave it queued). Starts the client like subscribe. See Presenting new notifications. |
poll(interval): () => void | Polls 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(): () => void | Keeps a realtime connection open while something is subscribed and the page is visible. Returns a function that stops this request. |
getState(): NotificationState | The 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. |
options | The 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
expand once after a401, 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.codeis"unauthorized") until you callidentify()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 | |
|---|---|
code | An API error code ("unauthorized", "rate_limited", …) or "network_error" |
status | HTTP status, 0 for network errors |
details | error.details from the API |
retryAfter | Milliseconds, 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 callrefresh(). Polling pauses while realtime is open. - Visibility: polls only while the page is visible and at least one listener is
subscribed. Focus, visibility and
onlineevents 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-Afteris honoured. Without polling, only a failed first load is retried. - Tokens: with a
tokenfunction, a new token is fetched shortly beforeexp, and a401fetches 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) withRetry-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
tokenfunction beforeexp. - Refused:
forbidden(plan or origin) stops realtime and setserror;payment_requiredbehaves 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 | |
|---|---|
defaultTheme | The 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:
| Key | Default |
|---|---|
title | Notifications |
bell | Notifications |
unreadBadge | {count} unread notifications |
markAllAsRead | Mark all as read |
markAsRead / markAsUnread | Mark as read / Mark as unread |
archive / unarchive | Archive / Unarchive |
all / unread / archived | All / Unread / Archived |
allCategories | All categories |
empty | You're all caught up |
emptyUnread | No unread notifications |
emptyArchived | No archived notifications |
error / retry | Couldn't load notifications / Try again |
loading / loadMore | Loading… / Load more |
close | Close |
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): NotificationClickEvent | The { item, preventDefault(), defaultPrevented } event the React SDKs pass to onNotificationClick. |
navigateTo(url, newTab = false): void | Web 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(): () => void | Call 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(): boolean | Loads 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): string | Tab text for all, unread or archived. |
emptyLabel(labels, status): string | Empty-state text for that tab. |
tabForKey(tabs, current, key): FeedStatus | null | Tab to select for ArrowLeft, ArrowRight (wrapping), Home and End, else null. |
bellLabel(labels, unreadCount): string | The bell's accessible name, e.g. "Notifications, 3 unread notifications". |
badgeText(unseenCount, max = 99): string | null | Badge text ("99+" above max), or null to hide the badge. |
mergeClientTheme(client, remoteTheme, ...overrides): ThemeConfig | Dashboard theme (state.config.theme) → client.options.theme → overrides. Pass the result to resolveTheme. |
resolveClientLabels(client, ...overrides): NotificationLabels | Defaults → client.options.labels → overrides. |
Clock and color scheme
| Export | |
|---|---|
createNowTicker(interval = NOW_TICK_INTERVAL_MS): NowTicker | A useSyncExternalStore-compatible clock: now() (stable between ticks) and subscribe(listener). It only ticks while subscribed. |
getNowTicker(interval = NOW_TICK_INTERVAL_MS): NowTicker | One shared ticker per interval for every UI on the page. |
NOW_TICK_INTERVAL_MS | 60000 |
getSystemDark(): boolean | Whether prefers-color-scheme is dark; false without matchMedia (SSR, native). |
subscribeSystemDark(listener): () => void | Calls 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: string | The CSS, with the built-in defaults declared on .ns-root and .ns-root[data-color-scheme="dark"]. |
injectStyles(doc = document): void | Adds it once as <style id="notificationstream-styles">. Safe to call repeatedly; no-op during SSR. |
icons | SVG 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, …).