Add a notification bell with an unread badge and an inbox panel to a website — plain JS, React or Next.js.
The bell is the in-app history; web push wakes the browser. Set up web push first — the bell stays empty until the browser registers.
Add the bell
The widget is three files in native-notify/web/: native-notify-bell.js (plain JS), NativeNotifyBell.jsx (React and the headless hook) and nativeNotifyBell.css. Drop them into your site, or ask Agent Notify to add them in a pull request.
// app/site-header.tsx
"use client";
import NativeNotifyBell from "../native-notify/web/NativeNotifyBell";
export function SiteHeader() {
return (
<header>
{/* your header… */}
<NativeNotifyBell appId={123} webKey="nnweb_…" title="Notifications" />
</header>
);
}
Import the stylesheet once — in app/layout.tsx (App Router) or pages/_app.tsx (Pages Router):
import "../native-notify/web/nativeNotifyBell.css";
<link rel="stylesheet" href="/native-notify/web/nativeNotifyBell.css" />
<script type="module">
import { mountNativeNotifyBell } from "/native-notify/web/native-notify-bell.js";
mountNativeNotifyBell({
appId: window.NN_CONFIG.appId, // your app id
webKey: window.NN_CONFIG.webKey, // the publishable web key — safe in page source
title: "Notifications",
position: "bottom-right", // or mount into your own header
});
</script>
mount takes an element or a selector. The stylesheet pins .nn-bell to a corner with position: fixed; override it in your CSS to place the bell inline:
mountNativeNotifyBell({ appId, webKey, mount: "#header-notifications" });
Build your own UI
The headless hook returns the same data and actions. It loads the unread count itself — call refresh() for the first page of entries:
"use client";
import { useEffect } from "react";
import { useNativeNotifyInbox } from "../native-notify/web/NativeNotifyBell";
export function InboxList() {
const inbox = useNativeNotifyInbox({ appId: 123, webKey: "nnweb_…", take: 20 });
const { refresh } = inbox;
// 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>
{inbox.entries.map((entry) => (
<li key={entry.entryId}>
<button type="button" onClick={() => inbox.markRead(entry)}>
{entry.read ? "" : "• "}{entry.title}
</button>
</li>
))}
</ul>
);
}
It returns deviceId, entries, total, unreadCount, loading, error and the actions refresh, refreshUnread, markRead(entry), markAllRead, remove(entry), clearInbox and loadMore.
Choose the device
The bell shows one device's inbox — by default this browser's, whose id lives in localStorage under nn_web_device_id, the key the web push snippet registers under. To show another device, pass its id; plain JS resolves it with getDeviceId:
mountNativeNotifyBell({
appId, webKey,
getDeviceId: async () => (await fetch("/api/me/device-id").then((r) => r.json())).deviceId,
});
The web key reads only devices it registered, so a native device answers 403 web_key_scope.
Options
React takes these as props. Plain JS (mountNativeNotifyBell, createNativeNotifyBell) takes the same names as options.
| Option | Default | What it does |
|---|---|---|
appId, webKey | — | Required. webKey is the publishable web key. |
deviceId | per-browser key | Whose inbox to show. React takes the resolved id here. |
getDeviceId | — | Plain JS only. async () => deviceId. |
storageKey | "nn_web_device_id" | localStorage key of the default id. |
apiBase | https://app.nativenotify.com | Point the widget at your own proxy. |
take | 20 | Rows per page, max 200. |
title | "Notifications" | Panel header. |
emptyText | "You are all caught up." | Empty state. |
showCount | true | false shows a plain dot. |
maxCount | 99 | Badge cap, shown as 99+. |
allowDelete | true | Show the delete button on each row. |
pollMs | 30000 | Unread poll interval in milliseconds. 0 disables polling; use 10000 or more. |
theme | light and dark defaults | Color overrides, like { accent: "#2563eb", dot: "#ef4444", radius: "14px" }. Each key sets a CSS variable such as --nn-accent. |
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. |
showLoadMore | true | Plain JS only. A Load more button when more pages exist. |
onNotificationPress, onNavigate, onUnreadChange, onError | — | Callbacks: every row click, a deep-link handler (url, entry), the unread count on change, and request errors (plain JS only). |
className | — | React only. Extra class on the root element. |
Deep links
Put a url in the send's pushData:
{
"title": "New service times",
"message": "This Sunday: 9am and 11am.",
"pushData": { "url": "/news/service-times" }
}
A click marks the row read, then opens the URL. Only same-origin paths and http(s) URLs open; anything else is ignored. Pass onNavigate to route client-side.
The inbox API
The widget calls the six inbox endpoints with the web key where an app token would go — in the URL for GETs, in the body for POSTs. Your side of the same data — every send with its read counts, and who is registered — is in the dashboard's Inbox and Audience tabs.
Plans
On Premium every inbox call answers 403 plan_upgrade_required, and the panel shows that message to your visitors. Sends still write entries, so they appear after an upgrade. See what each plan includes.
A browser that hasn't registered with the web key answers 403 web_key_scope; the widget shows an empty inbox. Every other code is in Mobile notification inbox.
The web key
The widget's credential is visible in your page, so it uses the publishable web key: it can register one browser device and read or mark that device's own inbox — never send, list groups or subscribers, or touch another device. Copy it from App Settings → App keys, or read it with a dashboard session or MCP token:
GET https://app.nativenotify.com/api/apps/<APP_ID>/web-key
To keep even the web key out of your page, proxy the six calls through your own API and set apiBase. See App keys & token rotation.