Integrate Novu's in-app notification inbox into web applications. Supports React, Next.js, and vanilla JavaScript. Includes the Inbox component (bell icon + notification feed), composable components (Bell, Notifications, InboxContent, Preferences), headless hooks, branded theming, custom render props, multi-tenancy via contexts, tabs, localization, and HMAC security. Use when adding an in-app notification center, bell icon, notification feed, real-time notification updates, or building a personalized and branded notification experience.
69
86%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Add an in-app notification center to your web application. The Inbox component provides a bell icon, notification feed, read/archive management, action buttons, and real-time WebSocket updates — all theme-able and personalizable to match your product.
| Package | Use For |
|---|---|
@novu/react | React 18/19 applications |
@novu/nextjs | Next.js (App Router + Pages Router) |
@novu/js | Vanilla JavaScript / non-React frameworks |
npm install @novu/reactimport { Inbox } from "@novu/react";
function App() {
return (
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
subscriberHash="HMAC_HASH" // Required if HMAC encryption is enabled
/>
);
}This renders a bell icon with unread count. Clicking it opens a popover with the notification feed.
npm install @novu/nextjs// components/NotificationInbox.tsx
"use client";
import { Inbox } from "@novu/nextjs";
export function NotificationInbox() {
return (
<Inbox
applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APP_ID!}
subscriberId="subscriber-123"
subscriberHash="HMAC_HASH"
/>
);
}Important: The Inbox is a client component — use "use client" directive in Next.js App Router.
import { Inbox } from "@novu/nextjs";
export default function NotificationsPage() {
return (
<Inbox
applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APP_ID!}
subscriberId="subscriber-123"
subscriberHash="HMAC_HASH"
/>
);
}The <Inbox> component is composable. When you pass children, it acts as a context provider and you compose the UI from primitives:
| Component | Purpose |
|---|---|
<Bell /> | Bell icon with unread count |
<Notifications /> | Notification feed (header + list + footer) |
<InboxContent /> | Same as <Notifications /> plus the Preferences page |
<Preferences /> | Standalone preferences panel |
import { Inbox, Bell, Notifications, Preferences } from "@novu/react";
function App() {
return (
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
subscriberHash="HMAC_HASH"
>
<Bell />
<Notifications />
<Preferences />
</Inbox>
);
}Use these primitives to build a custom popover, modal, drawer, or full-page notification experience.
The Inbox is fully themeable via the appearance prop. It supports four keys:
| Key | Purpose |
|---|---|
baseTheme | Apply a predefined theme (e.g. dark) |
variables | Global design tokens (colors, fonts, radius, severity colors) |
elements | Per-element styles (style object, class string, or context callback) |
icons | Replace built-in icons with your own React components |
Styles are auto-injected into <head> (or the shadow root if rendered inside a shadow DOM). When both baseTheme and variables are provided, variables win.
Inspiration: the Inbox Playground showcases pre-styled variants like Notion and Reddit.
import { Inbox } from "@novu/react";
import { dark } from "@novu/react/themes";
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
appearance={{ baseTheme: dark }}
/><Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
appearance={{
variables: {
colorPrimary: "#0081F1",
colorBackground: "#ffffff",
colorForeground: "#1A1523",
colorPrimaryForeground: "#ffffff",
colorSecondary: "#F1F0EF",
colorCounter: "#E5484D",
colorCounterForeground: "#ffffff",
colorNeutral: "#E0DEDC",
colorShadow: "rgba(0,0,0,0.08)",
fontSize: "14px",
borderRadius: "8px",
colorSeverityHigh: "#E5484D",
colorSeverityMedium: "#F76808",
colorSeverityLow: "#3E63DD",
},
}}
/>Each element accepts a string of class names, a style object, or a function (context) => string for runtime conditionals.
import inboxStyles from "./inbox.module.css";
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
appearance={{
elements: {
bellIcon: ({ unreadCount }) =>
unreadCount.total > 10
? "p-4 bg-white rounded-full [--bell-gradient-end:var(--color-red-500)]"
: "p-4 bg-white rounded-full",
notification: ({ notification }) =>
notification.data?.priority === "high"
? "bg-red-50 ring-1 ring-red-300 rounded-lg"
: "bg-white rounded-lg shadow-sm hover:bg-gray-50",
notificationSubject: { fontWeight: 600 },
notificationBody: inboxStyles.body,
},
}}
/>To find an element key, inspect the DOM: any class starting with
nv-(visible just before a 🔔 emoji in DevTools) maps to a key inappearance.elements(drop thenv-prefix). TS autocomplete lists all available keys.
Replace any built-in icon by returning a React component from appearance.icons:
import { RiSettings3Fill, RiNotification3Fill } from "react-icons/ri";
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
appearance={{
icons: {
bell: () => <RiNotification3Fill />,
cogs: () => <RiSettings3Fill />,
},
}}
/>Common icon keys: bell, cogs, dots, arrowDown, arrowDropDown, arrowLeft, arrowRight, check, clock, trash, markAsRead, markAsUnread, markAsArchived, markAsUnarchived, email, sms, push, inApp, chat. To find more, inspect classes that start with nv- and contain a 🖼️ emoji.
Notifications and the bell are styled by severity (high, medium, low). Override colors via variables:
Severity is a visual dial only. The workflow-level
critical: trueflag is independent — it changes runtime delivery (bypass preferences, skip digest), not Inbox styling.criticalworkflows that should also stand out visually should setseverity: 'high'explicitly. Seedesign-workflow/references/severity-and-critical.mdfor the full design rules.
appearance: {
variables: {
colorSeverityHigh: "#E5484D",
colorSeverityMedium: "#F76808",
colorSeverityLow: "#3E63DD",
},
}…or per element:
appearance: {
elements: {
severityHigh__notificationBar: { backgroundColor: "red" },
severityHigh__bellContainer: "ring-2 ring-red-500",
severityGlowHigh__bellSeverityGlow: "bg-red-500",
},
}By default the bell takes the color of the highest-severity unread notification.
<Inbox
/* ... */
appearance={{ elements: { popoverContent: "novu-popover-content" } }}
/>.novu-popover-content { max-width: 500px; }
@media (max-width: 768px) { .novu-popover-content { max-width: 350px; } }
@media (max-width: 480px) { .novu-popover-content { max-width: 250px; } }See Branding & Styling Reference for the full variable list, severity element keys, dynamic callback signatures, and Notion/Reddit-style presets.
Override individual parts of a notification — keep the surrounding chrome (action buttons, hover state, etc.) intact:
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
renderBell={(unreadCount) => <MyBell count={unreadCount.total} />}
renderAvatar={(notification) => <Avatar src={notification.avatar} />}
renderSubject={(notification) => <strong>{notification.subject}</strong>}
renderBody={(notification) => <p>{notification.body}</p>}
renderDefaultActions={(notification) => <MyActions notification={notification} />}
renderCustomActions={(notification) => (
<PrimarySecondaryButtons notification={notification} />
)}
/>Use renderNotification only when you need full control of the item — you'll need to re-implement default actions (mark as read, archive, snooze) yourself.
<Inbox
/* ... */
renderNotification={(notification) => (
<div className="custom-row">
<h3>{notification.subject}</h3>
<p>{notification.body}</p>
</div>
)}
/>renderNotification receives the full notification — branch on tags, data, severity, or workflow.identifier:
renderNotification={(notification) => {
if (notification.severity === SeverityLevelEnum.HIGH) return <HighPriorityRow notification={notification} />;
if (notification.tags?.includes("billing")) return <BillingRow notification={notification} />;
if (notification.data?.priority === "high") return <UrgentRow notification={notification} />;
return <DefaultRow notification={notification} />;
}}To render rich HTML in subject / body:
dangerouslySetInnerHTML in a render prop:<Inbox
/* ... */
renderBody={(notification) => (
<div dangerouslySetInnerHTML={{ __html: notification.body }} />
)}
renderSubject={(notification) => (
<span dangerouslySetInnerHTML={{ __html: notification.subject }} />
)}
/>Only enable this if you fully control the trigger payload — raw HTML opens an XSS surface area.
Hook the Inbox into your router. Novu calls routerPush with the redirect.url defined in your workflow:
import { useRouter } from "next/navigation";
const router = useRouter();
<Inbox
/* ... */
routerPush={(path) => router.push(path)}
onNotificationClick={(notification) => track("inbox_notification_click", { id: notification.id })}
onPrimaryActionClick={(notification) => doSomething(notification.primaryAction)}
onSecondaryActionClick={(notification) => doSomethingElse(notification.secondaryAction)}
/>Works with React Router (useNavigate()), Remix (useNavigate()), Gatsby (navigate()), and any custom router.
See Personalization Reference for full render-prop signatures, renderCustomActions styling examples, popover composition with Radix / shadcn Drawer, and conditional UI patterns.
Group notifications into tabs by tags, severity, or data properties:
import { Inbox, SeverityLevelEnum } from "@novu/react";
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
tabs={[
{ label: "All", filter: { tags: [] } },
{ label: "Promotions", filter: { tags: ["promotions"] } },
{ label: "Security", filter: { tags: ["security", "alert"] } },
{ label: "Critical", filter: { severity: SeverityLevelEnum.HIGH } },
{ label: "High Priority", filter: { data: { priority: "high" } } },
{
label: "Billing",
filter: { tags: ["billing"], data: { entity: "invoice" } },
},
]}
/>OR logic.HIGH, MEDIUM, LOW).data comes from the data object defined per In-App step.Use the useCounts hook to render unread counts per tab.
Use Contexts to scope the Inbox to a tenant, workspace, or feature area. The Inbox shows only notifications whose trigger context matches the Inbox context exactly.
await novu.trigger({
workflowId: "invoice-paid",
to: { subscriberId: "user-123" },
payload: { amount: "$250" },
context: {
tenant: {
id: "acme-corp",
data: { name: "Acme Corporation", plan: "enterprise" },
},
},
});<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="user-123"
subscriberHash="HMAC_HASH"
context={{
tenant: {
id: "acme-corp",
data: { name: "Acme Corporation", plan: "enterprise" },
},
}}
/>contextHashBecause context is set client-side, a hostile user could swap tenant IDs. Generate an HMAC hash of the canonicalized context server-side:
import { createHmac } from "crypto";
import { canonicalize } from "@tufjs/canonical-json";
const context = {
tenant: { id: "acme-corp", data: { name: "Acme Corporation", plan: "enterprise" } },
};
const contextHash = createHmac("sha256", process.env.NOVU_SECRET_KEY!)
.update(canonicalize(context))
.digest("hex");Pass it alongside the context:
<Inbox
/* ... */
context={context}
contextHash={contextHash}
/>| Workflow Context | Inbox Context | Displayed? |
|---|---|---|
{ tenant: "acme" } | { tenant: "acme" } | ✅ |
{} | {} | ✅ |
{ tenant: "acme" } | {} | ❌ |
{} | { tenant: "acme" } | ❌ |
{ tenant: "acme" } | { tenant: "globex" } | ❌ |
Context that doesn't yet exist in Novu is auto-created. Existing context data is not auto-updated to prevent overwrites.
See Multi-Tenancy Reference for full setup, dashboard management, and dynamic content rendering with {{context}}.
Each In-App step supports a custom data object — up to 10 scalar key-value pairs (string, number, boolean, null; strings ≤ 256 chars) defined in the workflow editor. Values can be static ("status": "merged") or dynamic ("firstName": "{{subscriber.firstName}}").
Access it client-side as notification.data and use it for render decisions, conditional styling, and tab filtering.
<Inbox
/* ... */
renderNotification={(notification) => (
<div>
<span>{notification.data?.emoji}</span>
<strong>{notification.data?.firstName}</strong>
<p>{notification.body}</p>
</div>
)}
/>Type the data object globally for autocomplete:
declare global {
interface NotificationData {
reactionType?: string;
entityId?: string;
userName?: string;
}
}Don't store secrets in
data— it's returned to the client. Never spread the entire trigger payload intodata.
Mount the notification feed inside any popover, drawer, or page layout. Use <Bell /> (or your own trigger) plus <Notifications /> or <InboxContent />:
import { Inbox, InboxContent, Bell } from "@novu/react";
import { Popover, PopoverTrigger, PopoverContent } from "@radix-ui/react-popover";
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
>
<Popover>
<PopoverTrigger>
<Bell />
</PopoverTrigger>
<PopoverContent className="h-[600px] w-[400px] p-0">
<InboxContent />
</PopoverContent>
</Popover>
</Inbox>The same pattern works with shadcn <Drawer>, Headless UI, or a route-level page (mount <InboxContent /> directly without any popover). All customization props (appearance, localization, tabs, routerPush, render props) flow through the <Inbox> provider.
Override Inbox UI text — useful for multi-language apps or matching your product voice:
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
localization={{
locale: "en-US",
"inbox.filters.labels.default": "Notifications",
"inbox.filters.dropdownOptions.unread": "Unread only",
"notifications.emptyNotice": "You're all caught up.",
"notifications.actions.readAll": "Mark all as read",
"notification.actions.archive.tooltip": "Move to archive",
"preferences.title": "Notification Preferences",
dynamic: {
"new-comment-on-post": "Post comments",
"new-follower-digest": "New Follower Updates",
},
}}
/>dynamic map to localize workflow names shown in the Preferences UI.defaultLocalization.ts.Required in production to prevent subscriber impersonation. See https://docs.novu.co/platform/inbox/prepare-for-production for the full guide.
import { createHmac } from "crypto";
const subscriberHash = createHmac("sha256", process.env.NOVU_SECRET_KEY!)
.update(subscriberId)
.digest("hex");import hmac, hashlib
subscriber_hash = hmac.new(
NOVU_SECRET_KEY.encode(),
subscriber_id.encode(),
hashlib.sha256,
).hexdigest()<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
subscriberHash={subscriberHash}
/>If you also pass a context, generate a contextHash (see Multi-Tenancy).
applicationIdentifier is NOT the same as NOVU_SECRET_KEY — the app ID is a public identifier safe for client-side use. The secret key is server-only.inApp step — if your workflow doesn't include step.inApp(), nothing appears."use client" is required in Next.js App Router — the Inbox component is client-side only.@novu/react vs @novu/nextjs — use @novu/nextjs for Next.js apps (handles SSR edge cases), @novu/react for all other React apps.variables override baseTheme — when both are set in appearance, variables win. Set variables in dark/light themes intentionally.(context) => string returns class names, not style objects. For style objects use a static value.context={{}} to the Inbox hides any notification triggered with a non-empty context, and vice-versa.notification.data — it's sent to the client.renderNotification removes default actions — use granular render props (renderSubject, renderBody, renderAvatar, renderDefaultActions, renderCustomActions) when you want to keep mark-as-read / archive / snooze affordances.dangerouslySetInnerHTML in a render prop. Either alone has no effect.d921755
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.