BackFrameworks

React hooks and components

Build an inbox, a bell with an unread count, and a preferences page with typed hooks and components.

Provider

@notifykitjs/react works with React 18 and later. It talks to the handler routes, so mount those first (in Next.js, see the route handler), then wrap the signed-in part of your app:

npm install @notifykitjs/react
import { NotifyKitProvider } from "@notifykitjs/react"

<NotifyKitProvider options={{ baseUrl: "/api/notifykit", realtime: true }}>
	{children}
</NotifyKitProvider>
OptionDefaultDoes
baseUrl"/api/notifykit"Where the handler is mounted
realtimefalseOpen a live stream for inbox updates. Needs a realtime adapter on the server
credentials"same-origin"Passed to every fetch; use "include" for another origin
headersnoneAdded to every request, such as an Authorization token
fetchglobalThis.fetchA custom fetch, for tests or SSR
onRealtimeErrornoneCalled when the stream fails, for logging

The client is created once, on mount; changing options later has no effect. To share one client or build it yourself, pass client={createNotifyKitClient(...)} instead. Every hook under the same provider shares one store, so marking an item read in the inbox updates the bell’s count too.

useInbox()

components/inbox.tsx
"use client"
import { useInbox } from "@notifykitjs/react"

export function InboxList() {
	const { items, status, markRead, refresh } = useInbox()

	if (status === "error") return <button onClick={refresh}>Retry</button>
	if (status === "loading" && items.length === 0) return <Skeleton />
	if (items.length === 0) return <p>You're all caught up.</p>

	return items.map((item) => (
		<button
			key={item.id}
			onClick={() => markRead(item.id)}
			data-read={!!item.readAt}
		>
			{item.title}
		</button>
	))
}
ReturnsType
itemsInboxItem[], newest first
status"idle" | "loading" | "ready" | "error"
errorstring | null
unreadCountnumber
realtimeStatus"disconnected" | "connecting" | "connected"
refresh()Reload the list
markRead(id), alias markAsReadMark one item read
markAllRead()Mark everything read; resolves to the count
archive(id), unarchive(id)Archiving removes the item from the list
deleteItem(id), alias deleteDelete permanently
OptionDefaultDoes
autoLoadtrueFetch on mount
pollIntervalfalseRefetch every N milliseconds instead of using the realtime stream
onNewItemsnoneCalled with items that appeared since the last load, from a refresh, a poll, or the stream

status goes back to "loading" on every refresh, so check items.length before showing a skeleton, as above, or the list flickers on each poll.

Optimistic updates

Mark read, mark all read, archive, and delete change the list and the unread count at once, then call the server. If the server refuses, the change is rolled back, error is set, and the promise rejects, so catch it where you want to tell the user.

useUnreadCount()

For a badge when you are not rendering the list: it asks for the count alone, not the items.

const { unreadCount } = useUnreadCount()
if (unreadCount === 0) return null
return <span className="badge">{unreadCount > 99 ? "99+" : unreadCount}</span>

It returns unreadCount, status, error, and refresh(), and takes the same autoLoad and pollInterval options. If useInbox() is already on the page, its unreadCount costs nothing extra.

usePreferences()

const { isEnabled, update } = usePreferences()

<input
	type="checkbox"
	checked={isEnabled("comment_mentioned", "email")}
	onChange={(e) =>
		update({
			notificationId: "comment_mentioned",
			channels: { email: e.target.checked },
		})
	}
/>
ReturnsDoes
itemsThe recipient’s saved per-notification preferences
status, errorAs in useInbox()
isEnabled(notificationId, channel)False only if a saved preference turns that channel off
update({ notificationId, channels })Save channels for one notification, optimistically, rolling back on error
refresh()Reload

A full settings page

client.notifications.list() returns every notification’s id, channels, category, description, and required flag, which is what a settings page needs to lay itself out:

const client = useNotifyKitClient()
const { isEnabled, update } = usePreferences()
const [defs, setDefs] = useState<NotificationMetadata[]>([])
useEffect(() => { client.notifications.list().then(setDefs) }, [client])

return defs.map((n) => (
	<fieldset key={n.id}>
		<legend>{n.description ?? n.id}</legend>
		{n.channels.map((ch) => (
			<label key={ch}>
				<input
					type="checkbox"
					disabled={n.required}
					checked={isEnabled(n.id, ch as ChannelType)}
					onChange={(e) =>
						update({
							notificationId: n.id,
							channels: { [ch]: e.target.checked },
						})
					}
				/>
				{ch}
			</label>
		))}
	</fieldset>
))

Group by n.category for sections. Required notifications cannot be turned off, so disable their toggles.

Components

Two unstyled components cover the common case; both take render props for your own markup.

import { Inbox, NotificationBell } from "@notifykitjs/react"

<NotificationBell render={({ unreadCount }) => <BellIcon count={unreadCount} />} />

<Inbox
	emptyState={<p>You're all caught up.</p>}
	renderItem={({ item, markRead }) => (
		<Row item={item} onOpen={() => markRead(item.id)} />
	)}
/>
  • <NotificationBell /> renders (3) by default, with an aria-label giving the count. It uses useInbox(), so it loads the list.
  • <Inbox /> renders a list with each item’s title, body, an Open link for safe http(s) action URLs, and a Mark read button, plus loading, error, and empty states.

Recipes

Toasts for new items

onNewItems fires only for items that arrive after the first load, so it will not toast the whole inbox on page load:

useInbox({
	onNewItems: (items) => items.forEach((item) => toast(item.title)),
})

A card per notification type

const CARDS: Record<string, React.ComponentType<{ item: InboxItem }>> = {
	team_invite: InviteCard,
	comment_mentioned: MentionCard,
}

function Card({ item }: { item: InboxItem }) {
	const Render = CARDS[item.notificationId] ?? GenericCard
	return <Render item={item} />
}

Keep the fallback: a browser still running last week’s bundle will receive notification types it has never seen.

Tabs and grouping

Filter items in memory for Unread (!item.readAt), or group by item.createdAt. Archived items are not in the default list; client.inbox.list({ archived: true }) loads them.

Accessibility

  • Give the bell a label with the count, such as `Notifications, ${unreadCount} unread`.
  • Put aria-live="polite" on the list or toast region so new items are announced.
  • Label each action with its item: Mark “Rey mentioned you” as read, not just Mark read.

Client Without React

createNotifyKitClient() is the same client the hooks use, for Vue, Svelte, or plain scripts. It takes the provider’s options.

import { createNotifyKitClient } from "@notifykitjs/react"

const client = createNotifyKitClient({ baseUrl: "/api/notifykit", realtime: true })
const unsubscribe = client.subscribe(() => render(client.getState()))

await client.inbox.list()
client.connect() // open the realtime stream; disconnect() closes it

It has inbox (list, unreadCount, markRead, markAllRead, archive, unarchive, deleteItem), preferences (list, update, and the global and category methods), and notifications.list(). Inside a provider, useNotifyKitClient() returns it.

Testing

Point the provider’s fetch at a handler over an in-memory NotifyKit, and component tests run the real routes with no server or mocks:

const notify = createNotifyKit({
	notifications: [commentMentioned] as const,
	database: memoryAdapter(),
	providers: { email: fakeEmailProvider() },
})
const handler = createHandler(notify, { identify: () => "test_user" })

function Wrapper({ children }: { children: React.ReactNode }) {
	return (
		<NotifyKitProvider
			options={{
				fetch: (input, init) =>
					handler(new Request(new URL(String(input), "http://t"), init)),
			}}
		>
			{children}
		</NotifyKitProvider>
	)
}

Send a notification with notify.send(), render your component inside Wrapper, and assert on what appears. Testing and dev mode covers the server side.