Skip to content

A notification bell in React

This builds a small bell on top of In-app notifications. Every plan includes notifications.

If you would rather not build one, the bell widget is a single script tag with the same behaviour. Build your own when the bell has to be part of your React tree.

  • A secret key (nv_live_...) on your server, to send notifications.
  • A publishable key (nv_pub_...) in your frontend, to read them.
  • The project’s signing secret on your server, to compute each user’s user_hash. See Identity verification.

Step 1: Send a notification from your server

Section titled “Step 1: Send a notification from your server”
// Server-side: Next.js route handler, server action, or any Node/Edge function
async function notifyUser(userId: string, title: string, body: string) {
const res = await fetch('https://api.nordva.dev/v1/notifications', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NORDVA_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userId, title, body, action_url: '/dashboard', icon: 'check' }),
});
// 201: sent. 403 PLAN_LIMIT_REACHED: this month's notifications are used up.
}
await notifyUser('user_abc123', 'Export ready', 'Your CSV export has been generated.');

To post to everyone instead, send audience: "all" and no user_id.

Step 2: Give the page the user id and its hash

Section titled “Step 2: Give the page the user id and its hash”
// Server-side, where you know who is signed in
import { createHmac } from 'node:crypto';
const userHash = createHmac('sha256', process.env.NORDVA_NOTIFICATIONS_SECRET!).update(user.id).digest('hex');
// Pass user.id and userHash to the page as props. Never the secret.
'use client';
import { useEffect, useState, useCallback } from 'react';
interface Notification {
id: string;
title: string;
body: string;
action_url: string | null;
icon: string;
created_at: string;
read: boolean;
broadcast: boolean;
}
const NORDVA_KEY = process.env.NEXT_PUBLIC_NORDVA_KEY!;
const API = 'https://api.nordva.dev/v1/notifications';
export function NotificationBell({ userId, userHash }: { userId: string; userHash: string }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unread, setUnread] = useState(0);
const [pollSeconds, setPollSeconds] = useState(60);
const [open, setOpen] = useState(false);
const headers = { 'Authorization': `Bearer ${NORDVA_KEY}`, 'X-Nordva-User-Hash': userHash };
const poll = useCallback(async () => {
const res = await fetch(`${API}/inbox?user_id=${encodeURIComponent(userId)}`, { headers });
if (!res.ok) return;
const { data } = await res.json();
setNotifications(data.notifications);
setUnread(data.unread_count);
setPollSeconds(data.poll_interval_seconds);
}, [userId, userHash]);
useEffect(() => {
void poll();
const interval = setInterval(poll, pollSeconds * 1000);
return () => clearInterval(interval);
}, [poll, pollSeconds]);
async function markRead(n: Notification) {
if (!n.read) {
await fetch(`${API}/${n.id}/read`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId }),
});
setNotifications(prev => prev.map(x => (x.id === n.id ? { ...x, read: true } : x)));
setUnread(u => Math.max(0, u - 1));
}
if (n.action_url) window.location.href = n.action_url;
}
return (
<div className="bell">
<button onClick={() => setOpen(o => !o)} aria-label={`Notifications, ${unread} unread`}>
🔔 {unread > 0 && <span className="bell-count">{unread}</span>}
</button>
{open && (
<div className="bell-panel">
{notifications.length === 0 ? (
<p>No notifications</p>
) : (
notifications.map(n => (
<button key={n.id} className={n.read ? 'bell-item' : 'bell-item unread'} onClick={() => void markRead(n)}>
<strong>{n.title}</strong>
<span>{n.body}</span>
</button>
))
)}
</div>
)}
</div>
);
}

Style the classes in your own stylesheet: inline style attributes are blocked by a strict Content Security Policy.

PATCH /v1/notifications/read-all with { "user_id": "..." } and the same header marks everything read, for a “Mark all read” button.

ErrorCauseFix
403 USER_HASH_INVALIDNo user_hash, a wrong one, or no signing secret yetCompute the hash on your server with the project’s signing secret. See Identity verification.
403 PLAN_LIMIT_REACHEDThis month’s notifications are used upWait for the 1st (UTC) or upgrade at launch.nordva.dev/pricing.
429 RATE_LIMITEDThe bell asked more often than the plan’s hourly budget per userPoll at poll_interval_seconds.
401 INVALID_API_KEYWrong keySecret key to send, publishable key in the browser.
403 KEY_INSUFFICIENT_PERMISSIONSA publishable key was used to sendSend from your server with the secret key.