A notification center keeps users informed about important events. Here is how to build one with a dropdown panel and real-time updates.
Notification Types
// types/notifications.ts
export type NotificationType = "info" | "success" | "warning" | "error" | "mention" | "update";
export interface Notification {
id: string;
type: NotificationType;
title: string;
body: string;
read: boolean;
createdAt: string;
actionUrl?: string;
actor?: {
name: string;
avatar?: string;
};
}
Notification Store
"use client";
import { createContext, useContext, useCallback, useReducer, useEffect } from "react";
import type { Notification } from "@/types/notifications";
interface NotificationState {
notifications: Notification[];
unreadCount: number;
loading: boolean;
}
type Action =
| { type: "SET_NOTIFICATIONS"; payload: Notification[] }
| { type: "ADD_NOTIFICATION"; payload: Notification }
| { type: "MARK_READ"; payload: string }
| { type: "MARK_ALL_READ" }
| { type: "REMOVE"; payload: string }
| { type: "SET_LOADING"; payload: boolean };
function reducer(state: NotificationState, action: Action): NotificationState {
switch (action.type) {
case "SET_NOTIFICATIONS":
return {
...state,
notifications: action.payload,
unreadCount: action.payload.filter((n) => !n.read).length,
loading: false,
};
case "ADD_NOTIFICATION":
return {
...state,
notifications: [action.payload, ...state.notifications],
unreadCount: state.unreadCount + (action.payload.read ? 0 : 1),
};
case "MARK_READ": {
const notifications = state.notifications.map((n) =>
n.id === action.payload ? { ...n, read: true } : n
);
return {
...state,
notifications,
unreadCount: notifications.filter((n) => !n.read).length,
};
}
case "MARK_ALL_READ":
return {
...state,
notifications: state.notifications.map((n) => ({ ...n, read: true })),
unreadCount: 0,
};
case "REMOVE":
return {
...state,
notifications: state.notifications.filter((n) => n.id !== action.payload),
unreadCount: state.notifications.filter(
(n) => n.id !== action.payload && !n.read
).length,
};
case "SET_LOADING":
return { ...state, loading: action.payload };
default:
return state;
}
}
interface NotificationContextValue extends NotificationState {
markRead: (id: string) => void;
markAllRead: () => void;
remove: (id: string) => void;
refresh: () => void;
}
const NotificationContext = createContext<NotificationContextValue | null>(null);
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, {
notifications: [],
unreadCount: 0,
loading: true,
});
const fetchNotifications = useCallback(async () => {
dispatch({ type: "SET_LOADING", payload: true });
const res = await fetch("/api/notifications");
const data = await res.json();
dispatch({ type: "SET_NOTIFICATIONS", payload: data });
}, []);
useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
// SSE for real-time updates
useEffect(() => {
const eventSource = new EventSource("/api/notifications/stream");
eventSource.addEventListener("notification", (e) => {
const notification = JSON.parse(e.data) as Notification;
dispatch({ type: "ADD_NOTIFICATION", payload: notification });
});
eventSource.onerror = () => {
eventSource.close();
// Reconnect after delay
setTimeout(() => fetchNotifications(), 5000);
};
return () => eventSource.close();
}, [fetchNotifications]);
const markRead = useCallback(async (id: string) => {
dispatch({ type: "MARK_READ", payload: id });
await fetch(`/api/notifications/${id}/read`, { method: "PATCH" });
}, []);
const markAllRead = useCallback(async () => {
dispatch({ type: "MARK_ALL_READ" });
await fetch("/api/notifications/read-all", { method: "PATCH" });
}, []);
const remove = useCallback(async (id: string) => {
dispatch({ type: "REMOVE", payload: id });
await fetch(`/api/notifications/${id}`, { method: "DELETE" });
}, []);
return (
<NotificationContext.Provider
value={{ ...state, markRead, markAllRead, remove, refresh: fetchNotifications }}
>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error("useNotifications must be used within NotificationProvider");
return ctx;
}
Bell Icon with Badge
"use client";
import { useNotifications } from "./NotificationProvider";
import { useState, useRef, useEffect } from "react";
import { NotificationPanel } from "./NotificationPanel";
export function NotificationBell() {
const { unreadCount } = useNotifications();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<div ref={containerRef} className="relative">
<button
onClick={() => setOpen(!open)}
className="relative p-2 rounded-lg hover:bg-muted"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && <NotificationPanel onClose={() => setOpen(false)} />}
</div>
);
}
Notification Panel
"use client";
import { useNotifications } from "./NotificationProvider";
import type { Notification } from "@/types/notifications";
interface NotificationPanelProps {
onClose: () => void;
}
export function NotificationPanel({ onClose }: NotificationPanelProps) {
const { notifications, loading, markRead, markAllRead, remove, unreadCount } =
useNotifications();
return (
<div className="absolute right-0 top-full mt-2 w-80 bg-popover border rounded-lg shadow-lg z-50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<h3 className="font-semibold text-sm">Notifications</h3>
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-xs text-primary hover:underline"
>
Mark all read
</button>
)}
</div>
{/* List */}
<div className="max-h-[400px] overflow-y-auto">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-14 bg-muted rounded animate-pulse" />
))}
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
No notifications yet
</div>
) : (
notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onRead={markRead}
onRemove={remove}
/>
))
)}
</div>
{/* Footer */}
{notifications.length > 0 && (
<div className="border-t px-4 py-2">
<a
href="/notifications"
className="text-xs text-primary hover:underline"
onClick={onClose}
>
View all notifications
</a>
</div>
)}
</div>
);
}
function NotificationItem({
notification,
onRead,
onRemove,
}: {
notification: Notification;
onRead: (id: string) => void;
onRemove: (id: string) => void;
}) {
const typeIcons: Record<string, string> = {
info: "bg-blue-100 text-blue-600",
success: "bg-green-100 text-green-600",
warning: "bg-yellow-100 text-yellow-600",
error: "bg-red-100 text-red-600",
mention: "bg-purple-100 text-purple-600",
update: "bg-gray-100 text-gray-600",
};
const timeAgo = getTimeAgo(new Date(notification.createdAt));
return (
<div
className={`flex gap-3 px-4 py-3 hover:bg-muted/50 cursor-pointer group ${
!notification.read ? "bg-primary/5" : ""
}`}
onClick={() => {
if (!notification.read) onRead(notification.id);
if (notification.actionUrl) window.location.href = notification.actionUrl;
}}
role="button"
tabIndex={0}
>
{/* Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${typeIcons[notification.type]}`}>
{notification.actor?.avatar ? (
<img
src={notification.actor.avatar}
alt=""
className="w-8 h-8 rounded-full"
/>
) : (
<span className="text-xs font-bold">
{notification.type[0].toUpperCase()}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug">
{notification.actor && (
<span className="font-medium">{notification.actor.name} </span>
)}
{notification.title}
</p>
{notification.body && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{notification.body}
</p>
)}
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>
{/* Unread dot / Remove */}
<div className="flex items-center">
{!notification.read ? (
<div className="w-2 h-2 rounded-full bg-primary" />
) : (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(notification.id);
}}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-1"
aria-label="Remove notification"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
);
}
function getTimeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
return date.toLocaleDateString();
}
Need In-App Notifications?
We build real-time notification systems for web and mobile apps. Contact us to discuss your requirements.