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

FileWhat it is
native-notify/web/nativeNotifyBell.jsPlain-JS ES module: mountNativeNotifyBell(options) and createNativeNotifyBell(options). No dependencies, no build step.
native-notify/web/NativeNotifyBell.jsxReact / Next.js component ("use client") plus the useNativeNotifyInbox headless hook.
native-notify/web/nativeNotifyBell.cssStyles + theming for both entry points (shared class names).
native-notify/web/README.mdThe 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 url in the notification's pushData and 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 theme object or CSS custom properties; light/dark defaults follow prefers-color-scheme.
  • Accessibility — a real button with aria-label/aria-expanded, a labelled non-modal role="dialog" panel, Escape + click-outside to close (focus returns to the bell), a polite live region for the unread count, and prefers-reduced-motion support.

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.

CallEndpoint
ListGET /api/universal/inbox/:appId/:appToken?deviceId=&take=&skip=
Unread countGET /api/universal/inbox/:appId/:appToken/unread-count?deviceId=
Mark one readPOST /api/universal/inbox/read — { appId, appToken, deviceId, entryId }
Mark all readPOST /api/universal/inbox/read-all — { appId, appToken, deviceId }
Delete onePOST /api/universal/inbox/delete — { appId, appToken, deviceId, entryId } (soft, idempotent)
ClearPOST /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
    }
  ]
}
  • take defaults to 50 and must be an integer 1..200 (a larger value is a 400, never a silent cap); skip defaults to 0. total and unread cover 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:

CallResponse
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 deviceId instead (there is no getDeviceId prop).

  • keep a per-browser key (the default) — a stable id persisted in localStorage under nn_web_device_id (change the key with storageKey), 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.

OptionDefaultWhat it does
appId / appToken—Required. Read them from your config; never commit real values.
deviceIdper-browser keyWhose 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.
apiBasehttps://app.nativenotify.comPoint the widget at your own proxy (see App token on the client).
take20Rows per page (max 200).
title"Notifications"Panel header.
emptyText"You are all caught up."Empty state.
showCounttrueNumeric badge; false shows a plain dot.
maxCount99Badge cap (99+).
allowDeletetrueShow the per-row delete button.
pollMs30000Unread 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.
themelight/dark defaultsColor 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).
showLoadMoretruePlain 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.

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 keyCSS variableUsed for
accent--nn-accentFocus rings, links, unread titles.
background--nn-surfaceThe bell button.
card--nn-cardThe panel.
border--nn-borderBorders and separators.
title--nn-titleHeadings and row titles.
text--nn-textBody text.
mutedText--nn-mutedTimestamps, empty state.
dot--nn-dotUnread dot and badge background.
badgeText--nn-badge-textBadge text.
delete--nn-deleteDelete button.
radius--nn-radiusPanel 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 / appToken at 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.url and click the row
  • Run the accessibility pass (keyboard, screen reader, reduced motion)