Chapter 44: Notifications
A complete notification system includes in-app notifications, toast messages, and optionally push and email notifications. This chapter covers each layer.
Toast Notifications (Ephemeral)​
For immediate feedback after actions. We recommend sonner — a lightweight, accessible toast library:
pnpm add sonner
// shared/components/feedback/toaster.tsx
import { Toaster } from "sonner";
// Add to your root layout
export function AppToaster() {
return (
<Toaster
position="bottom-right"
richColors
closeButton
toastOptions={{
duration: 5000,
className: "font-sans",
}}
/>
);
}
// Usage anywhere in the app
import { toast } from "sonner";
// After successful mutation
toast.success("Project created", {
description: "Your project has been created successfully.",
});
// Error notification
toast.error("Failed to delete project", {
description: "Please try again later.",
});
// Promise-based (shows loading → success/error automatically)
toast.promise(createProject(input), {
loading: "Creating project...",
success: "Project created!",
error: "Failed to create project",
});
// With action button
toast("Project archived", {
action: {
label: "Undo",
onClick: () => unarchiveProject(projectId),
},
});
Persistent In-App Notifications​
For notifications that persist and have read/unread state:
// features/notifications/components/notification-center.tsx
import { useNotifications, useMarkAsRead } from "../hooks/use-notifications";
function NotificationCenter() {
const { data: notifications = [], isLoading } = useNotifications();
const markAsRead = useMarkAsRead();
const unreadCount = notifications.filter((n) => !n.read).length;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<BellIcon className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="border-b px-4 py-3">
<h3 className="font-semibold">Notifications</h3>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<p className="p-4 text-center text-sm text-muted-foreground">
No notifications
</p>
) : (
notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onRead={() => markAsRead.mutate(notification.id)}
/>
))
)}
</div>
</PopoverContent>
</Popover>
);
}
function NotificationItem({
notification,
onRead,
}: {
notification: Notification;
onRead: () => void;
}) {
return (
<button
className={cn(
"flex w-full gap-3 border-b px-4 py-3 text-left transition-colors hover:bg-muted/50",
!notification.read && "bg-primary/5"
)}
onClick={onRead}
>
<NotificationIcon type={notification.type} />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">{notification.title}</p>
<p className="text-xs text-muted-foreground">{notification.message}</p>
<p className="text-xs text-muted-foreground">
{formatRelativeDate(notification.createdAt)}
</p>
</div>
{!notification.read && (
<div className="mt-1 h-2 w-2 shrink-0 rounded-full bg-primary" />
)}
</button>
);
}
Real-Time Notification Delivery​
Combine with the SSE hook from Chapter 24:
// In your authenticated layout
function AuthenticatedLayout() {
// SSE for real-time notifications
useNotificationStream(); // from Chapter 24
return (
<AppLayout>
<Outlet />
<AppToaster />
</AppLayout>
);
}
Summary​
- ✅ Toast notifications (sonner) for immediate action feedback
- ✅ Persistent notifications with read/unread state and notification center
- ✅ Real-time delivery via SSE integration with TanStack Query cache
- ✅ Promise-based toasts for automatic loading → success/error transitions