docs/.mintlify/skills/inbox-integration/references/personalization.md
The Inbox can be personalized at every level — from swapping the bell icon, to per-row layout, to fully custom popovers and routing. This reference covers render props, click handling, conditional display, HTML content, and composing the Inbox primitives inside your own UI.
Render props let you replace specific parts of the Inbox while keeping the surrounding chrome (default actions, hover states, animations, accessibility).
| Prop | Replaces | Keep default actions? |
|---|---|---|
renderBell | Bell icon | N/A |
renderAvatar | Notification avatar | ✅ |
renderSubject | Subject line | ✅ |
renderBody | Body text | ✅ |
renderDefaultActions | Mark-as-read / archive / snooze buttons | ❌ (you implement them) |
renderCustomActions | Primary + secondary action buttons | ✅ |
renderNotification | Entire notification row | ❌ (you implement everything) |
renderBellReceives the unread count broken down by severity:
import { Inbox, SeverityLevelEnum } from "@novu/react";
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
renderBell={(unreadCount) => (
<button className="relative p-2">
<BellIcon />
{unreadCount.severity[SeverityLevelEnum.HIGH] > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full px-1">
{unreadCount.severity[SeverityLevelEnum.HIGH]}
</span>
)}
</button>
)}
/>
renderAvatar<Inbox
/* ... */
renderAvatar={(notification) => (
)}
/>
renderSubject / renderBody<Inbox
/* ... */
renderSubject={(notification) => (
<strong className="text-sm">{notification.subject}</strong>
)}
renderBody={(notification) => (
<p className="text-xs text-gray-600">{notification.body}</p>
)}
/>
renderDefaultActionsReplaces the built-in mark-as-read / archive / snooze affordances. You're responsible for re-implementing them.
import { Inbox } from "@novu/react";
import { Archive, Check } from "lucide-react";
<Inbox
/* ... */
renderDefaultActions={(notification) => (
<div className="flex gap-2">
<button title="Mark as read"><Check className="w-4 h-4" /></button>
<button title="Archive"><Archive className="w-4 h-4" /></button>
</div>
)}
/>
renderCustomActions (primary / secondary buttons)Useful for matching brand button styles without re-implementing default actions:
<Inbox
/* ... */
renderCustomActions={(notification) => (
<div className="flex gap-2 mt-3">
{notification.secondaryAction && (
<button className="px-3 py-1 rounded border border-gray-300 text-sm">
{notification.secondaryAction.label}
</button>
)}
{notification.primaryAction && (
<button className="px-3 py-1 rounded bg-pink-500 text-white text-sm">
{notification.primaryAction.label}
</button>
)}
</div>
)}
/>
renderNotification (full takeover)Use sparingly — you lose all built-in affordances:
<Inbox
/* ... */
renderNotification={(notification) => (
<article className="p-4 border-b">
<header className="flex justify-between">
<h3 className="font-semibold">{notification.subject}</h3>
<time className="text-xs text-gray-500">
{new Date(notification.createdAt).toLocaleString()}
</time>
</header>
<p>{notification.body}</p>
</article>
)}
/>
renderNotification receives the full notification object, including tags, data, severity, and workflow. Branch on whichever signal best fits the UI.
renderNotification={(notification) => {
if (notification.tags?.includes("billing")) {
return <BillingRow notification={notification} />;
}
return <DefaultRow notification={notification} />;
}}
renderNotification={(notification) => {
if (notification.workflow?.identifier === "comment-mention") {
return <CommentMentionRow notification={notification} />;
}
return <DefaultRow notification={notification} />;
}}
renderNotification={(notification) => {
if (notification.data?.priority === "high") {
return (
<div className="bg-red-50 ring-1 ring-red-300 p-3 rounded-lg">
<strong>{notification.subject}</strong>
<p>{notification.body}</p>
</div>
);
}
return <DefaultRow notification={notification} />;
}}
import { SeverityLevelEnum } from "@novu/react";
renderNotification={(notification) => {
if (notification.severity === SeverityLevelEnum.HIGH) {
return <UrgentRow notification={notification} />;
}
return <DefaultRow notification={notification} />;
}}
By default Novu sanitizes the subject and body to prevent XSS. To allow rich HTML:
dangerouslySetInnerHTML.Only enable this if you fully control the trigger payload. Raw HTML opens an XSS surface area.
<Inbox
/* ... */
renderBody={(notification) => (
<div dangerouslySetInnerHTML={{ __html: notification.body }} />
)}
/>
<Inbox
/* ... */
renderSubject={(notification) => (
<span dangerouslySetInnerHTML={{ __html: notification.subject }} />
)}
/>
<Inbox
/* ... */
renderNotification={(notification) => (
<div className="p-4 border-b">
<h3 dangerouslySetInnerHTML={{ __html: notification.subject }} />
<div dangerouslySetInnerHTML={{ __html: notification.body }} />
</div>
)}
/>
Example workflow content (works with both Liquid variables and HTML tags):
{{subscriber.firstName}}, <b>good news!</b> Your <i>analytics dashboard</i>
is ready. <a href="https://app.example.com/analytics" target="_blank">Open it</a>.
routerPush integrationWhen a notification has a redirect.url defined in the workflow, Novu calls routerPush(url) so navigation stays inside your SPA router.
// Next.js App Router
import { useRouter } from "next/navigation";
const router = useRouter();
<Inbox /* ... */ routerPush={(path) => router.push(path)} />;
// React Router v6
import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
<Inbox /* ... */ routerPush={(path) => navigate(path)} />;
// Remix
import { useNavigate } from "@remix-run/react";
const navigate = useNavigate();
<Inbox /* ... */ routerPush={(path) => navigate(path)} />;
// Gatsby
import { navigate } from "gatsby";
<Inbox /* ... */ routerPush={(path) => navigate(path)} />;
onNotificationClickOverride click behavior entirely (open a drawer, modal, etc.):
<Inbox
/* ... */
onNotificationClick={(notification) => {
if (notification.data?.entity === "issue") {
openIssueDrawer(notification.data.entityId);
return;
}
if (notification.redirect?.url) {
window.location.href = notification.redirect.url;
}
}}
/>
onPrimaryActionClick / onSecondaryActionClick<Inbox
/* ... */
onPrimaryActionClick={(notification) => acceptInvite(notification.data.inviteId)}
onSecondaryActionClick={(notification) => declineInvite(notification.data.inviteId)}
/>
The <Inbox> component is composable. When passed children, it acts as a context provider — drop the feed into any popover, drawer, or page.
| Component | Renders |
|---|---|
<Bell /> | Bell icon trigger |
<Notifications /> | Header + scrollable list + footer (no Preferences page) |
<InboxContent /> | Same as <Notifications /> plus the Preferences page |
<Preferences /> | Standalone preferences |
import { Inbox, Notifications } from "@novu/react";
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
>
<Notifications />
</Inbox>
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>
"use client";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Inbox, InboxContent } from "@novu/react";
export function NotificationDrawer() {
return (
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
>
<Drawer direction="right">
<DrawerTrigger className="rounded-full border px-4 py-2">
Notifications
</DrawerTrigger>
<DrawerContent className="w-[400px]">
<DrawerHeader>
<DrawerTitle>Inbox</DrawerTitle>
</DrawerHeader>
<InboxContent />
</DrawerContent>
</Drawer>
</Inbox>
);
}
import { Inbox, InboxContent } from "@novu/react";
export default function NotificationsPage() {
return (
<main className="max-w-3xl mx-auto py-12">
<h1 className="text-2xl font-semibold mb-6">Notifications</h1>
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
>
<InboxContent />
</Inbox>
</main>
);
}
All customization props (appearance, localization, tabs, routerPush, render props, context) flow through the <Inbox> provider and apply to children automatically.
Override Inbox UI text per locale. Localization changes UI chrome only — to translate notification content, use Workflow Translations.
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="subscriber-123"
localization={{
locale: "en-US",
"inbox.filters.labels.default": "Notifications",
"inbox.filters.labels.unread": "Unread",
"inbox.filters.labels.archived": "Archived",
"inbox.filters.labels.snoozed": "Snoozed",
"inbox.filters.dropdownOptions.unread": "Unread only",
"inbox.filters.dropdownOptions.default": "All",
"notifications.emptyNotice": "You're all caught up.",
"notifications.actions.readAll": "Mark all as read",
"notifications.actions.archiveAll": "Archive all",
"notifications.actions.archiveRead": "Archive read",
"notification.actions.read.tooltip": "Mark as read",
"notification.actions.unread.tooltip": "Mark as unread",
"notification.actions.archive.tooltip": "Archive",
"notification.actions.unarchive.tooltip": "Unarchive",
"notification.actions.snooze.tooltip": "Snooze",
"notification.actions.unsnooze.tooltip": "Unsnooze",
"notification.snoozedUntil": "Snoozed until",
"snooze.options.anHourFromNow": "An hour from now",
"snooze.options.inOneDay": "Tomorrow",
"snooze.options.inOneWeek": "Next week",
"snooze.options.customTime": "Custom time...",
"preferences.title": "Notification Preferences",
"preferences.global": "Global Preferences",
"preferences.emptyNotice": "No notification specific preferences yet.",
dynamic: {
"new-comment-on-post": "Post comments",
"new-follower-digest": "New Follower Updates",
},
}}
/>
The full key list is in defaultLocalization.ts.
localization.dynamic is a Record<workflowId, string> used to display friendly workflow names in the Preferences UI:
localization={{
dynamic: {
"weekly-digest": "Weekly Digest",
"team-mention": "Team Mentions",
},
}}
const [locale, setLocale] = useState("en-US");
const localizationByLocale = {
"en-US": { "preferences.title": "Notification Preferences", locale: "en-US" },
"es-ES": { "preferences.title": "Preferencias de Notificación", locale: "es-ES" },
"fr-FR": { "preferences.title": "Préférences de Notification", locale: "fr-FR" },
};
<Inbox
/* ... */
localization={localizationByLocale[locale]}
/>;
Group notifications into filtered tabs:
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 High",
filter: { tags: ["billing"], data: { priority: "high" } },
},
]}
/>;
tags: workflow-level. Multiple tags use OR logic.severity: from the In-App step's severity. Accepts a single value or an array.data: matches keys defined in the In-App step's data object.tags + data + severity for narrower filters.To show counts per tab, use the useCounts hook.
"use client";
import { useRouter } from "next/navigation";
import { Inbox, SeverityLevelEnum } from "@novu/react";
import { dark } from "@novu/react/themes";
export function BrandedInbox({ subscriberId, subscriberHash }) {
const router = useRouter();
return (
<Inbox
applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APP_ID!}
subscriberId={subscriberId}
subscriberHash={subscriberHash}
routerPush={(path) => router.push(path)}
tabs={[
{ label: "All", filter: { tags: [] } },
{ label: "Mentions", filter: { tags: ["mention"] } },
{ label: "Critical", filter: { severity: SeverityLevelEnum.HIGH } },
]}
appearance={{
baseTheme: dark,
variables: {
colorPrimary: "#FB4CA3",
colorPrimaryForeground: "#FFFFFF",
borderRadius: "12px",
},
elements: {
notification: ({ notification }) =>
notification.data?.priority === "high"
? "bg-red-500/10 ring-1 ring-red-500/30"
: "",
notificationPrimaryAction__button: "bg-pink-500 hover:bg-pink-600",
},
}}
renderAvatar={(notification) => (
)}
localization={{
locale: "en-US",
"inbox.filters.labels.default": "All",
"notifications.emptyNotice": "Nothing new — you're all caught up.",
}}
/>
);
}