Notification Bell & Inbox (Web)
Add a notification bell with an unread badge and a popup inbox panel to any web site — plain JS, React or Next.js — reading each device's universal inbox for list, unread count, read/unread, delete and deep links. Agent Notify can add it to your repo in a pull request.
The web bell + inbox is a drop-in widget for your site: a bell button with an unread badge and an inbox panel — list, read/unread, delete, and deep links — that reads the universal inbox API for a device. It ships for plain JS, React and Next.js, has no dependencies, and needs no build step.
Pair it with browser push:
The widget is the in-app history; web push is what wakes the browser. Set that up first — it takes one service worker and one registration call, and there is nothing to paste: see the Web Push Recipe.
Agent Notify can add it for you:
You do not have to wire this up by hand. Ask Agent Notify to "add a notification bell to my web app" — it opens a pull request on an agent-notify/* branch with the three widget files plus a guide, exactly like the push setup flow. See Let the agent add it.
Let the agent add it
Agent Notify treats the widget as an add-on: an opt-in capability applied on top of whatever stack your repo uses, in the same setup pull request (see Framework Recipes for the base recipes). The tool call is:
{
"tool": "start_setup_session",
"args": {
"task": "add a notification bell + inbox to my site",
"addons": ["web-bell-inbox"]
}
}
The same add-on can be fetched as a guide without touching the repo:
{
"tool": "get_setup_guide",
"args": { "stack": "web", "addons": ["web-bell-inbox"] }
}
What the pull request adds (new files only — nothing existing is modified):
| File | What it is |
|---|---|
native-notify/web/nativeNotifyBell.js | Plain-JS ES module: mountNativeNotifyBell(options) and createNativeNotifyBell(options). No dependencies, no build step. |
native-notify/web/NativeNotifyBell.jsx | React / Next.js component ("use client") plus the useNativeNotifyInbox headless hook. |
native-notify/web/nativeNotifyBell.css | Styles + theming for both entry points (shared class names). |
native-notify/web/README.md | The add-on's own setup guide and checklist. |
If you already merged the push-setup PR, running the add-on session again writes only the new widget files (files that already exist are never overwritten or duplicated).
What the widget does
- Bell button with a live unread badge — a numeric count (
99+past the cap) or a plain dot. - Inbox panel: entries newest first with title, body, time, and a per-row unread dot.
- Read state per entry — opening the panel does not mark everything read; a row is marked read when it is opened, and Mark all read is an explicit action. Read state lives on the server, so every tab and the app agree.
- Delete per row (soft delete, idempotent) — hide the button with
allowDelete: false. - Deep links — put a
urlin the notification'spushDataand the row opens it on click. - Live unread count by polling (default every 30s, paused while the tab is hidden). The API has no SSE channel.
- Theming — a
themeobject or CSS custom properties; light/dark defaults followprefers-color-scheme. - Accessibility — a real button with
aria-label/aria-expanded, a labelled non-modalrole="dialog"panel, Escape + click-outside to close (focus returns to the bell), a polite live region for the unread count, andprefers-reduced-motionsupport.
Quick start — React / Next.js
Only appId and appToken are required — every other prop in Options is optional. The component file starts with "use client", so a Server Component (a Next.js App Router layout or page) can render it directly:
// app/site-header.tsx — adjust the import path to where the add-on put the file
import NativeNotifyBell from "../native-notify/web/NativeNotifyBell";
// NEXT_PUBLIC_* values are inlined at build time; TypeScript types them string | undefined.
const appId = process.env.NEXT_PUBLIC_NN_APP_ID;
const appToken = process.env.NEXT_PUBLIC_NN_APP_TOKEN;
export function SiteHeader() {
return (
<header>
{/* your header… */}
{appId && appToken ? (
<NativeNotifyBell appId={appId} appToken={appToken} title="Notifications" />
) : null}
</header>
);
}
Import the stylesheet once for the whole site — in the App Router's root layout (app/layout.tsx), or pages/_app.tsx in the Pages Router:
import "../native-notify/web/nativeNotifyBell.css";
Callback props (onNavigate, onNotificationPress, onUnreadChange) are functions, so pass them from a Client Component — for example to route deep links with the Next.js router instead of a full page load:
"use client";
import { useRouter } from "next/navigation";
import NativeNotifyBell from "../native-notify/web/NativeNotifyBell";
export function HeaderBell({ appId, appToken }: { appId: string; appToken: string }) {
const router = useRouter();
return (
<NativeNotifyBell
appId={appId}
appToken={appToken}
theme={{ accent: "#2563eb" }}
onNavigate={(url: string) => router.push(url)}
/>
);
}
For your own UI instead of the built-in panel, use the headless hook — the same data and actions. It loads nothing on its own: call refresh() for the first page (it also sets unreadCount):
"use client";
import { useEffect } from "react";
import { useNativeNotifyInbox } from "../native-notify/web/NativeNotifyBell";
// The entry fields this list renders (every field is listed under "The inbox API behind it").
type InboxEntry = { entryId: number; title: string; body: string; sentAt: string; read: boolean };
export function InboxList({ appId, appToken }: { appId: string; appToken: string }) {
const inbox = useNativeNotifyInbox({ appId, appToken, take: 20 });
const { refresh } = inbox;
const entries: InboxEntry[] = inbox.entries;
// Fetch page 1 — this re-runs once the per-browser device id has resolved.
useEffect(() => {
refresh();
}, [refresh]);
if (inbox.error) return <p role="alert">{inbox.error}</p>;
return (
<ul>
{entries.map((entry) => (
<li key={entry.entryId}>
<button type="button" onClick={() => inbox.markRead(entry)}>
{entry.read ? "" : "• "}
{entry.title}
</button>
</li>
))}
</ul>
);
}
The hook returns deviceId, entries, total, unreadCount, loading, error and the actions refresh, refreshUnread, markRead(entry), markAllRead, remove(entry), clearInbox and loadMore.
Quick start — plain JS (any site)
<link rel="stylesheet" href="/native-notify/web/nativeNotifyBell.css" />
<script type="module">
import { mountNativeNotifyBell } from "/native-notify/web/nativeNotifyBell.js";
mountNativeNotifyBell({
appId: window.NN_CONFIG.appId, // your config — never commit real values
appToken: window.NN_CONFIG.appToken,
title: "Notifications",
position: "bottom-right", // or mount into your own header
});
</script>
mount accepts an element or a selector, so the bell can live in your header instead of floating in a corner:
mountNativeNotifyBell({ appId, appToken, mount: "#header-notifications" });
The inbox API behind it
The widget is a thin client of the universal inbox — one inbox per device, keyed by the same stable deviceId universal registration used. All endpoints authenticate with your app id + app token; the GETs carry them in the URL, the POSTs in the body.
| Call | Endpoint |
|---|---|
| List | GET /api/universal/inbox/:appId/:appToken?deviceId=&take=&skip= |
| Unread count | GET /api/universal/inbox/:appId/:appToken/unread-count?deviceId= |
| Mark one read | POST /api/universal/inbox/read — { appId, appToken, deviceId, entryId } |
| Mark all read | POST /api/universal/inbox/read-all — { appId, appToken, deviceId } |
| Delete one | POST /api/universal/inbox/delete — { appId, appToken, deviceId, entryId } (soft, idempotent) |
| Clear | POST /api/universal/inbox/clear — { appId, appToken, deviceId } (soft, idempotent) |
The list answers 200 with the device's own entries, newest first:
{
"ok": true,
"deviceId": "device-abc",
"take": 50,
"skip": 0,
"total": 12,
"unread": 3,
"entries": [
{
"entryId": 4181,
"appId": 123,
"deviceId": "device-abc",
"subscriberId": null,
"environment": "production",
"title": "New service times",
"body": "This Sunday: 9am and 11am.",
"data": { "url": "/news/service-times" },
"audienceType": "all",
"source": "api",
"sentAt": "2026-09-24T14:03:11.000Z",
"readAt": null,
"read": false
}
]
}
takedefaults to 50 and must be an integer 1..200 (a larger value is a400, never a silent cap);skipdefaults to 0.totalandunreadcover the device's whole visible inbox, so "load more" is exact.- Only the requesting device's own entries are ever returned — the same device key on another app sees nothing.
- Every universal send writes one entry per targeted device, in the background — an entry can appear a moment after the send response.
The other calls answer 200 too:
| Call | Response |
|---|---|
| Unread count | { "ok": true, "deviceId", "unread" } |
| Mark one read | { "ok": true, "entryId", "read": true, "readAt" } — marking it again keeps the first readAt |
| Mark all read | { "ok": true, "deviceId", "marked" } — how many entries changed |
| Delete one | { "ok": true, "entryId", "removed" } — removed: false when it was already deleted |
| Clear | { "ok": true, "deviceId", "removed" } — how many entries were cleared |
Errors are machine-readable: { "error": { "code", "message", "field"? } } — 400 for missing_field / invalid_field / invalid_device_id / invalid_entry_id / invalid_body, 404 entry_not_found (an entry that does not exist for this device, or marking a deleted one read) and 404 app_not_found.
Which deviceId does a browser use?
A browser registers itself as a web device when the site runs the Web Push subscribe snippet — that snippet stores its device id under nn_web_device_id, the same key this widget defaults to, so the bell shows exactly the pushes this browser receives. The widget can instead:
-
show the same inbox as the app — pass the device id your backend knows for the logged-in user. The plain-JS widget can resolve it for you with
getDeviceId:mountNativeNotifyBell({ appId, appToken, getDeviceId: async () => (await fetch("/api/me/device-id").then((r) => r.json())).deviceId, });The React component and hook take the resolved value as
deviceIdinstead (there is nogetDeviceIdprop). -
keep a per-browser key (the default) — a stable id persisted in
localStorageundernn_web_device_id(change the key withstorageKey), useful for previewing the widget before wiring identity.
Options
The React component takes these as props; the plain-JS mountNativeNotifyBell / createNativeNotifyBell take the same names in its options object.
| Option | Default | What it does |
|---|---|---|
appId / appToken | — | Required. Read them from your config; never commit real values. |
deviceId | per-browser key | Whose inbox to show. |
getDeviceId | — | Plain JS only: async () => deviceId, resolved before the first request. |
storageKey | "nn_web_device_id" | localStorage key of the per-browser default id. |
apiBase | https://app.nativenotify.com | Point the widget at your own proxy (see App token on the client). |
take | 20 | Rows per page (max 200). |
title | "Notifications" | Panel header. |
emptyText | "You are all caught up." | Empty state. |
showCount | true | Numeric badge; false shows a plain dot. |
maxCount | 99 | Badge cap (99+). |
allowDelete | true | Show the per-row delete button. |
pollMs | 30000 | Unread poll interval; 0 disables polling. The minimum is 10000 — the plain-JS widget raises a smaller value to 10 s, the React hook treats it as off. |
theme | light/dark defaults | Color override — see Themes. |
position | "bottom-right" | Floating corner: bottom-right, bottom-left, top-right or top-left. |
mount | <body> | Plain JS only: element or selector to mount into (your header). |
showLoadMore | true | Plain JS only: a Load more button when more pages exist. |
onNotificationPress | — | Called on every row click. |
onNavigate | — | (url, entry) — handle the deep link yourself instead of navigating. |
onUnreadChange | — | Called with the unread count on every change. |
onError | — | Plain JS only: called with request errors. |
className | — | React only: extra class on the root element. |
Deep links
Convention: put a url inside the notification's pushData — the same key the mobile SDKs use (see Push Data & Taps):
{
"title": "New service times",
"message": "This Sunday: 9am and 11am.",
"pushData": { "url": "/news/service-times" }
}
Clicking the row marks it read and then opens the URL — same-origin paths and http(s) URLs only; anything else is ignored. Pass onNavigate when your app is an SPA and prefers client-side routing.
Themes
Pass a subset of these keys in theme; the widget maps each to the CSS custom property named after it. You can also set the variables in your own stylesheet — the widget ships light defaults with a dark prefers-color-scheme set.
| Theme key | CSS variable | Used for |
|---|---|---|
accent | --nn-accent | Focus rings, links, unread titles. |
background | --nn-surface | The bell button. |
card | --nn-card | The panel. |
border | --nn-border | Borders and separators. |
title | --nn-title | Headings and row titles. |
text | --nn-text | Body text. |
mutedText | --nn-muted | Timestamps, empty state. |
dot | --nn-dot | Unread dot and badge background. |
badgeText | --nn-badge-text | Badge text. |
delete | --nn-delete | Delete button. |
radius | --nn-radius | Panel corners. |
theme: { accent: "#2563eb", dot: "#ef4444", radius: "14px" }
App token on the client
The widget calls the API from the browser, so appId and appToken are visible in your page — the same situation as shipping them inside a mobile app. The inbox endpoints are device-scoped: a caller can only read, mark and delete that device's own entries. If your policy still requires the token to stay server-side, proxy the six calls above through your own API (the widget accepts apiBase, and your proxy can strip/replace the credentials).
Checklist
- Point
appId/appTokenat your configuration (env vars, never committed) - Decide the
deviceId: the app's registered device id, or the per-browser default - Import
nativeNotifyBell.css(or copy its variables into your stylesheet) - Mount the bell (React component or
mountNativeNotifyBell) - Send a test notification with
pushData.urland click the row - Run the accessibility pass (keyboard, screen reader, reduced motion)