Cara Membuat Fitur Lonceng Notifikasi Real-Time dan Web Push di Next.js dengan Cloudflare Edge

Notification Bell Next.js terintegrasi dengan Web Push dan Cloudflare.
Dalam era pengembangan web modern, menjaga keterlibatan (engagement) pengguna adalah kunci utama keberhasilan sebuah platform digital, baik itu portal berita, blog edukasi, maupun situs e-commerce. Pengguna masa kini mendambakan pembaruan informasi yang cepat dan instan tanpa harus memuat ulang halaman secara manual.
Artikel teknis ini akan membahas secara mendalam dan sistematis bagaimana merancang serta mengimplementasikan sistem notifikasi end-to-end yang elegan pada platform berbasis Next.js dan Cloudflare Edge. Sistem ini menggabungkan dua kekuatan besar: Web Push Notification (untuk menjangkau pengguna di luar peramban) dan In-App Notification Bell (lonceng notifikasi interaktif di dalam web untuk desktop maupun seluler).
Arsitektur dan Alur Data Sistem
Sebelum masuk ke tahap penulisan kode, penting untuk memahami bagaimana data mengalir dari hulu ke hilir. Sistem notifikasi ganda ini dirancang dengan prinsip efisiensi tinggi tanpa membebani server utama:
- Penerbitan Konten (CMS Studio): Saat administrator menerbitkan artikel baru melalui panel CMS (Studio Worker), data artikel disimpan ke dalam basis data (seperti Cloudflare D1).
- Pemicu Otomatis (Trigger): Sistem secara otomatis mengirimkan sinyal ke API Edge untuk menyebarkan sinyal Web Push melalui OneSignal, sekaligus memperbarui antrean data artikel aktif.
- Antarmuka Pengguna (UI Client): Komponen lonceng interaktif di dalam web (desktop dan mobile) secara cerdas menarik data artikel terbaru menggunakan penyimpanan lokal browser (
localStorage) untuk mengelola status pesan yang belum dibaca (unread count).
Langkah 1: Membangun Komponen Inti Lonceng Notifikasi (notification-bell.tsx)
Komponen lonceng adalah jantung dari sistem interaksi di dalam web. Komponen ini dirancang agar sepenuhnya responsif—menyesuaikan lebar layar secara otomatis di perangkat seluler maupun komputer—serta dilengkapi indikator titik oranye berdenyut halus (pulse) yang menjadi standar desain modern.
Buat file baru pada direktori proyek Anda di components/notification-bell.tsx:
"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { Bell } from "lucide-react";
export default function NotificationBell() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
async function fetchNotifications() {
try {
// Ganti URL di bawah ini dengan endpoint API artikel Anda yang sebenarnya
const res = await fetch('https://studio.domain-anda.com/api/articles?status=published');
if (!res.ok) {
console.warn("API Artikel belum siap, lonceng dinonaktifkan sementara.");
return;
}
const json = await res.json();
const articlesArray = Array.isArray(json.data) ? json.data : (Array.isArray(json) ? json : []);
let initTime = localStorage.getItem('notification_system_init');
if (!initTime) {
initTime = new Date().toISOString();
localStorage.setItem('notification_system_init', initTime);
}
const initTimestamp = new Date(initTime).getTime();
const realNewArticles = articlesArray.filter(
(a: any) => new Date(a.created_at).getTime() > initTimestamp
);
if (realNewArticles.length > 0) {
const latestArticles = realNewArticles.slice(0, 5);
setNotifications(latestArticles);
const lastRead = localStorage.getItem('last_read_notifications');
if (lastRead) {
const lastReadTime = new Date(lastRead).getTime();
const unread = latestArticles.filter((a: any) => new Date(a.created_at).getTime() > lastReadTime).length;
setUnreadCount(unread);
} else {
setUnreadCount(latestArticles.length);
}
} else {
setNotifications([]);
setUnreadCount(0);
}
} catch (error) {
console.error("Gagal memuat notifikasi:", error);
}
}
fetchNotifications();
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleBellClick = () => {
setIsOpen(!isOpen);
if (!isOpen && unreadCount > 0) {
setUnreadCount(0);
localStorage.setItem('last_read_notifications', new Date().toISOString());
}
};
return (
<div className="relative flex-shrink-0" ref={dropdownRef}>
<button
onClick={handleBellClick}
className="relative p-1.5 sm:p-2 text-gray-600 hover:text-blue-600 transition-colors rounded-full hover:bg-gray-100 focus:outline-none"
aria-label="Notifikasi"
>
<Bell className="w-5 h-5 sm:w-6 sm:h-6" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 flex h-2.5 w-2.5 sm:h-3 sm:w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 sm:h-3 sm:w-3 bg-orange-500 border-2 border-white shadow-sm"></span>
</span>
)}
</button>
{isOpen && (
<div className="fixed left-4 right-4 sm:absolute sm:right-0 sm:left-auto mt-2 sm:w-96 bg-white rounded-xl shadow-2xl border border-gray-100 overflow-hidden z-[100] transform transition-all origin-top-right">
<div className="p-3 sm:p-4 border-b border-gray-100 bg-gray-50 flex justify-between items-center">
<h3 className="font-bold text-gray-800 text-xs sm:text-sm">Notifikasi Terbaru</h3>
{unreadCount > 0 && (
<span className="bg-orange-100 text-orange-600 text-[9px] sm:text-[10px] px-2 py-1 rounded-full font-bold uppercase tracking-wide">
{unreadCount} Baru
</span>
)}
</div>
<div className="max-h-[300px] sm:max-h-[350px] overflow-y-auto">
{notifications.length > 0 ? (
<div className="divide-y divide-gray-50">
{notifications.map((article: any, index) => (
<Link
key={article.id || index}
href={`/post/${article.slug || article.id}`}
onClick={() => setIsOpen(false)}
className={`flex items-start gap-3 p-3 sm:p-4 transition-colors hover:bg-blue-50 cursor-pointer block w-full ${index < unreadCount ? 'bg-blue-50/30' : 'bg-white'}`}
>
<div className="flex-shrink-0 mt-1">
<div className={`w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full ${index < unreadCount ? 'bg-blue-500' : 'bg-gray-300'}`}></div>
</div>
<div>
<p className={`text-xs sm:text-sm text-gray-800 line-clamp-2 leading-tight ${index < unreadCount ? 'font-bold' : 'font-medium'}`}>
{article.title}
</p>
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 line-clamp-1">
{article.excerpt || "Ada pembaruan konten menarik yang baru saja dipublikasikan."}
</p>
</div>
</Link>
))}
</div>
) : (
<div className="p-6 text-center text-gray-500 text-xs flex flex-col items-center">
<Bell className="w-6 h-6 sm:w-8 sm:h-8 text-gray-300 mb-2 opacity-50" />
<span>Belum ada notifikasi baru</span>
<span className="text-[9px] text-gray-400 mt-1">Notifikasi akan muncul setelah ada artikel baru diterbitkan.</span>
</div>
)}
</div>
</div>
)}
</div>
);
}
Catatan Penting: Pada baris fetch('https://studio.domain-anda.com/api/articles?status=published'), pastikan Anda mengganti URL dummy tersebut dengan domain API basis data artikel yang Anda miliki.
Langkah 2: Integrasi Lonceng pada Panel Atas Komputer (Desktop Top Bar)
Setelah komponen inti siap, langkah berikutnya adalah memasangnya ke dalam baris navigasi atas (Top Bar) khusus untuk layar komputer atau laptop agar letaknya strategis di sebelah kanan.
import NotificationBell from "./notification-bell"
export function TopBar() {
return (
<header className="sticky top-0 z-[60] w-full shrink-0 border-b border-border bg-background shadow-sm">
<div className="mx-auto flex min-h-[64px] w-full max-w-[1400px] items-center justify-between pr-2 md:pr-4">
<div className="flex min-w-0 flex-1 items-center">
{/* Elemen navigasi kategori */}
</div>
<div className="flex shrink-0 items-center gap-2 pl-2">
<div className="hidden items-center md:flex mr-1">
<NotificationBell />
</div>
</div>
</div>
</header>
)
}
Langkah 3: Integrasi Lonceng pada Menu Samping Seluler (Mobile Sidebar Left)
Untuk perangkat seluler, penempatan lonceng yang paling optimal adalah di bagian paling atas sidebar menu samping, berdampingan dengan logo platform dan avatar profil pengguna.
import NotificationBell from "./notification-bell"
export function SidebarLeft() {
return (
<aside className="fixed left-0 top-0 z-[100] flex h-screen w-[290px] flex-col border-r bg-white shadow-2xl">
<div className="flex w-full items-center justify-between border-b px-3 py-4 lg:justify-center lg:py-6">
<div className="flex min-w-0 items-center gap-2">
<img src="/path-logo.png" alt="Logo" className="w-10 h-10 object-contain" />
<div className="flex min-w-0 flex-col">
<span className="font-bold text-sm truncate">NAMA PLATFORM</span>
<span className="text-[9px] text-gray-500 truncate">Slogan Platform Anda</span>
</div>
</div>
<div className="flex items-center gap-1.5 lg:hidden flex-shrink-0">
<NotificationBell />
</div>
</div>
</aside>
)
}
Langkah 4: Membuat API Route Edge untuk Web Push (app/api/notify/route.ts)
Agar sistem dapat mengirimkan notifikasi keluar aplikasi secara otomatis melalui layanan seperti OneSignal, kita memerlukan fungsi perantara (API Route) yang berjalan di lingkungan Edge Runtime Cloudflare Pages.
import { NextResponse } from 'next/server';
import { getRequestContext } from '@cloudflare/next-on-pages';
export const runtime = 'edge';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export async function OPTIONS() {
return NextResponse.json({}, { headers: corsHeaders });
}
export async function POST(request: Request) {
try {
const env = getRequestContext().env as any;
const ONE_SIGNAL_APP_ID = env.ONESIGNAL_APP_ID;
const ONE_SIGNAL_REST_KEY = env.ONESIGNAL_REST_KEY;
if (!ONE_SIGNAL_APP_ID || !ONE_SIGNAL_REST_KEY) {
return NextResponse.json({ error: "Kunci API OneSignal belum dikonfigurasi!" }, { status: 500, headers: corsHeaders });
}
const body = await request.json();
const response = await fetch('https://onesignal.com/api/v1/notifications', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Authorization': `Basic ${ONE_SIGNAL_REST_KEY}`
},
body: JSON.stringify({
app_id: ONE_SIGNAL_APP_ID,
included_segments: ['Subscribed Users'],
headings: { en: body.title || "Informasi Terbaru" },
contents: { en: body.body || "Ada konten baru yang diterbitkan." },
url: body.url || "https://domain-anda.com"
})
});
const data = await response.json();
return NextResponse.json({ success: true, message: "Notifikasi berhasil disebar!", data }, { status: 200, headers: corsHeaders });
} catch (error: any) {
return NextResponse.json({ error: "Gagal mengirim notifikasi: " + error.message }, { status: 500, headers: corsHeaders });
}
}
Kesimpulan
Dengan merangkai seluruh tahapan di atas—mulai dari komponen In-App Notification Bell yang cerdas dengan penyimpanan lokal, penyesuaian tata letak yang bersih pada Desktop Top Bar dan Mobile Sidebar, hingga integrasi Web Push API berbasis Edge—platform Anda kini memiliki sistem pemberitahuan modern yang setara dengan aplikasi berkelas industri. Seluruh proses berjalan secara otomatis, cepat, dan aman tanpa mengorbankan performa situs web utama Anda.


