Aviary
Recipes

React inbox widget

Drop a notification inbox into a React app with one component. The @dudousxd/nestjs-notifications-react package ships <Inbox/>, a NotificationsProvider, and the useNotifications / useUnreadCount hooks — all consuming the read API and SSE stream you already expose.

You already have the backend inbox: the database channel persists notifications, the REST controller exposes them, and the SSE channel pushes live updates. @dudousxd/nestjs-notifications-react is the frontend half — a bell, a badge, a dropdown, and hooks — so you don't hand-write fetch calls and EventSource wiring.

pnpm add @dudousxd/nestjs-notifications-react

The one-liner: <Inbox/>

Point it at your API and you have a working inbox — bell, unread badge, dropdown list, mark-as-read, mark-all-read, and infinite scroll:

AppHeader.tsx
import { Inbox } from '@dudousxd/nestjs-notifications-react';

export function AppHeader() {
  return (
    <header>
      <Inbox
        clientOptions={{ baseUrl: '/notifications' }}
        sseUrl="/notifications/stream"
      />
    </header>
  );
}

That's the whole integration. clientOptions.baseUrl is where the REST controller is mounted; sseUrl is the live stream endpoint. Drop sseUrl and the widget falls back to polling.

Configure once with NotificationsProvider

If several components need the inbox (a header bell, a settings page, a count somewhere else), wrap your app once so the client and stream URL are shared:

main.tsx
import { NotificationsProvider } from '@dudousxd/nestjs-notifications-react';

root.render(
  <NotificationsProvider
    clientOptions={{ baseUrl: '/notifications', credentials: 'include' }}
    sseUrl="/notifications/stream"
  >
    <App />
  </NotificationsProvider>,
);

Now <Inbox/> and the hooks read their config from context — no props needed.

The hooks

When you want your own UI, the hooks give you the data and the mutations:

useUnreadCount
import { useUnreadCount } from '@dudousxd/nestjs-notifications-react';

const { count, refresh } = useUnreadCount();

count updates live off the SSE stream (or polls when there's no sseUrl). This is the live badge in one line.

useNotifications
import { useNotifications } from '@dudousxd/nestjs-notifications-react';

const {
  notifications,   // the loaded page(s), newest first
  loading,
  error,
  hasMore,
  loadMore,        // fetch the next page
  markAsRead,      // (id) => optimistic, rolls back on error
  markAllAsRead,
  remove,          // (id) => delete
  refresh,
} = useNotifications();

Mutations are optimistic — the UI updates immediately and rolls back if the request fails.

Cross-device read sync

useNotifications() also returns applyReadEvent — patch the local list when another tab or device marks a notification read, so this view updates without a refetch. Feed it the stream's onRead:

const { applyReadEvent } = useNotifications();
useNotificationsStream({ url: '/notifications/stream', onRead: applyReadEvent });

onRead fires with a ReadSyncEvent ({ notificationId, readAt }; notificationId: null = all read). <Inbox/> and NotificationsProvider already wire this when an sseUrl is set — you only need it when you compose the hooks yourself. The server side (binding the SSE read publisher) is in the Real-time & in-app guide.

The bell on its own: NotificationBell

<Inbox/> renders a bell-with-badge that toggles the panel. When you're building your own panel (a slide-over, a route, a full-page inbox), you often want just the bell — the button and its unread badge — wired to your own open/close state. That's NotificationBell: a small, state-free component you drive with a count and an onClick.

HeaderBell.tsx
import { useState } from 'react';
import { NotificationBell, useUnreadCount } from '@dudousxd/nestjs-notifications-react';

export function HeaderBell() {
  const { count } = useUnreadCount();     // live off the SSE stream (or polling)
  const [open, setOpen] = useState(false);

  return (
    <>
      <NotificationBell count={count} expanded={open} onClick={() => setOpen((o) => !o)} />
      {open && <MyNotificationPanel onClose={() => setOpen(false)} />}
    </>
  );
}

Because it takes count as a prop, it isn't tied to the hooks at all — feed it a number from anywhere (a WebSocket, your own store, a server-rendered value). Pairing it with useUnreadCount() is the common case, but not required.

NotificationBellProps:

PropTypeDefaultDescription
countnumberUnread count shown in the badge; the badge is hidden when 0.
onClick() => voidClick handler — typically toggles your panel.
maxnumber99Caps the displayed number: count > max renders as ${max}+ (e.g. 99+).
expandedbooleanReflected as aria-expanded so the button announces your panel's open state.
ariaLabelstringautoAccessible label. Defaults to describing the unread count ("Notifications, 3 unread").
iconReact.ReactNodebell glyphOverride the default bell SVG with your own icon.
className / styleStyle hooks; the component ships minimal inline styles you can extend or replace.

The button carries aria-haspopup="true" and the aria-expanded you pass, so it's accessible as a disclosure control out of the box. It's the same bell <Inbox/> uses internally — reach for NotificationBell when you want the bell without the bundled dropdown.

Customizing the row

<Inbox/> renders a sensible default row from item.data: title, body, relative time, and — when present — a progress bar (data.progress, 0100) for long-running notifications and a download/action link (data.action of { label, url }, or a flat actionUrl/downloadUrl). The same conventions are exposed as pure helpers — notificationProgress(item) and notificationAction(item) (alongside notificationTitle/notificationBody) — so a custom renderItem can reuse them instead of reaching into item.data by hand:

import { notificationProgress, notificationAction } from '@dudousxd/nestjs-notifications-react';

<Inbox
  renderItem={(item, { markAsRead }) => {
    const progress = notificationProgress(item); // number | null
    const action = notificationAction(item);     // { label, url } | null
    return (
      <MyRow
        title={item.data.title as string}
        progress={progress}
        action={action}
        unread={!item.readAt}
        onClick={() => markAsRead(item.id)}
      />
    );
  }}
/>

See Live / progress notifications for the producer side that drives the bar.

Other props: title, emptyState, onItemClick, className, and panelClassName. The widget ships with minimal inline styles and no CSS framework dependency, so it inherits your app's look and you restyle via the class hooks.

SSR-safe. The hooks only touch EventSource/window inside effects and guard for their presence, so the package renders fine under Next.js / Remix server rendering — the live connection opens on the client after hydration.

Auth

The client passes through whatever your API needs. Use cookies with credentials: 'include', or send a bearer token via headers:

<NotificationsProvider
  clientOptions={{
    baseUrl: '/notifications',
    headers: { Authorization: `Bearer ${token}` },
  }}
  sseUrl="/notifications/stream"
>

The hand-written NotificationsClient mirrors the REST controller exactly. Prefer to generate it? See Typed client with codegennestjs-codegen emits a typed client straight from your controller, so it can't drift from your routes.

On this page