Notify guides
04 · An inbox in React

An inbox in your React app.

A bell with the unread count, a list that updates in real time, mark read, archive and action buttons. One component, or hooks for your own design.

npm install @productcraft/notify-react (React 18 or later). It runs in the browser with a recipient token; your API key stays on your server. Next.js? See the App Router guide.


1

A token endpoint on your server

The browser asks for a token at start and again when one expires or is revoked. Mint it for the user your session says is signed in, never for an id the request names.

server.ts
import express from "express";
import { Notify } from "@productcraft/notify";
import { requireUser } from "@/lib/auth"; // your own session middleware

const notify = new Notify();
const app = express();

// The browser calls this whenever it needs a token: at start and when one expires.
app.get("/api/notify-token", requireUser, async (req, res) => {
  const { token } = await notify.recipientTokens.mint({
    recipient: req.user.id, // the signed-in user, never a value from the request
    ttl_seconds: 3600,
  });
  res.set("cache-control", "no-store").type("text/plain").send(token);
});

2

Drop in the inbox

Add your site’s origin under Notify › Settings › Client origins (while the list is empty, only http://localhost works). <NotifyInbox/> opens a popover with the items, marks the shown ones seen, and gets new ones over a real-time stream (polling every 30 seconds if the stream cannot connect). Up to two action buttons per item record which one was pressed (an inbox_item.interacted webhook).

Header.tsx
import { NotifyInbox, NotifyProvider } from "@productcraft/notify-react";
import "@productcraft/notify-react/styles.css"; // optional default theme, light and dark

const getToken = () => fetch("/api/notify-token").then((r) => r.text());

export function Header({ userId, locale }: { userId: string; locale: string }) {
  return (
    <header>
      {/* Remount when the user changes: the provider drops the old token and stream. */}
      <NotifyProvider key={userId} getToken={getToken} locale={locale}>
        <NotifyInbox onNew={(item) => console.log("new", item.title)} />
      </NotifyProvider>
    </header>
  );
}

3

Or build your own

useInbox gives the same state: items, unread and unseen counts, status, and optimistic marks that roll back if the server refuses. Hooks with the same filters share one store and one stream.

Bell.tsx
import { useInbox } from "@productcraft/notify-react";

export function Bell() {
  const { counts, items, markRead, markAllRead, interact, status } = useInbox({ tenant: "org_42" });
  if (status === "loading") return null;
  return (
    <section aria-label={`Notifications, ${counts.unread} unread`}>
      <button type="button" onClick={() => void markAllRead()}>Mark all read</button>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <button type="button" onClick={() => void markRead([item.id])}>{item.title}</button>
            {item.actions?.map((a) => (
              <button key={a.label} type="button" onClick={() => void interact(item.id, a.action_key ?? undefined)}>
                {a.label}
              </button>
            ))}
          </li>
        ))}
      </ul>
    </section>
  );
}

4

Theme, language and safety

  • The default theme is CSS variables, light and dark; it follows prefers-color-scheme, or force it with data-theme="dark".
  • Strings ship in English, European and Brazilian Portuguese, Spanish, French and German; pass locale, and strings to override any of them.
  • Item bodies are a Markdown subset rendered as React elements, never HTML; links and images are re-checked to be https:.
  • Accessible: the bell’s label carries the unread count, the popover traps focus and closes on Escape, new items are announced politely, and motion respects reduced motion.
notifications.css
.pcn-inbox, .pcn-prefs {
  --pcn-accent: #7c3aed;
  --pcn-radius: 6px;
  --pcn-font: "Inter", sans-serif;
}