Tech

Sistem Notifikasi Health Check di Next.js — Alert Sebelum User Komplen

Jangan tunggu user komplen. Setup health check dengan notifikasi real-time sebelum masalah jadi bencana.

👤 Zainul Fanani📅 1 April 2026⏱ 1 min read

📎 Source:notification-system-nextjs-health-checks.md — view on GitHub & star ⭐

Real-Time Notification System di Next.js dengan Auto-Health Checks

Dari zero notification ke dashboard bell yang hidup — lengkap dengan health check otomatis dan persistence.

Scenario

Dashboard monitoring di PT Contoh Engineering awalnya cuma menampilkan grafik dan tabel. User nggak tau kalau ada service yang down atau threshold yang terlampaui sampai mereka manually refresh halaman. Hasilnya? Insiden terdeteksi rata-rata 30 menit setelah kejadian.

Kita butuh sistem notifikasi yang: (1) muncul real-time di dashboard, (2) persisten antar session, dan (3) otomatis detect masalah lewat health check berkala.

Arsitektur

AHealth Check CronbrSetiap 5 menit  BService OK
AHealth Check CronbrSetiap 5 menit BService OK

Ada tiga layer di sini: producer (health check cron), store (in-memory + persisted), dan consumer (frontend via SSE/polling). Masing-masing bisa di-scale terpisah.

Step 1: Notification Store

Buat singleton class yang handle in-memory queue plus persistence:

// lib/notification-store.ts interface Notification { id: string; type: 'error' | 'warning' | 'info'; title: string; message: string; timestamp: number; read: boolean; source: string; // misal: "health-check", "system", "user" } class NotificationStore { private notifications: Notification[] = []; private subscribers: Set<(n: Notification[]) => void> = new Set(); private persistPath = '/data/notifications.json'; constructor() { this.load(); } // Load dari file saat startup private async load() { try { const fs = await import('fs/promises'); const data = await fs.readFile(this.persistPath, 'utf-8'); this.notifications = JSON.parse(data); } catch { this.notifications = []; } } // Simpan ke file setiap ada perubahan private async persist() { try { const fs = await import('fs/promises'); await fs.writeFile( this.persistPath, JSON.stringify(this.notifications, null, 2) ); } catch (err) { console.error('[NotificationStore] Persist failed:', err); } } // Tambah notifikasi baru + broadcast async add(notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) { const entry: Notification = { ...notification, id: crypto.randomUUID(), timestamp: Date.now(), read: false, }; this.notifications.unshift(entry); // Keep max 200 notifikasi biar nggak bengkak this.notifications = this.notifications.slice(0, 200); await this.persist(); this.broadcast(); return entry; } // Mark single / all as read async markRead(id?: string) { if (id) { const n = this.notifications.find(n => n.id === id); if (n) n.read = true; } else { this.notifications.forEach(n => (n.read = true)); } await this.persist(); this.broadcast(); } // Dapatkan unread count getUnreadCount() { return this.notifications.filter(n => !n.read).length; } // Dapatkan semua notifikasi (with pagination) getAll(limit = 50, offset = 0) { return this.notifications.slice(offset, offset + limit); } // Subscribe untuk real-time updates (SSE) subscribe(callback: (n: Notification[]) => void) { this.subscribers.add(callback); callback(this.notifications); // send current state immediately return () => this.subscribers.delete(callback); } private broadcast() { const snapshot = [...this.notifications]; this.subscribers.forEach(cb => cb(snapshot)); } } // Singleton — satu instance per server process export const notificationStore = new NotificationStore();

Step 2: Health Check Cron

Gunakan node-cron atau setInterval untuk periodic health check:

// lib/health-checker.ts import cron from 'node-cron'; import { notificationStore } from './notification-store'; interface HealthTarget { name: string; url: string; expectedStatus: number; timeoutMs?: number; } const targets: HealthTarget[] = [ { name: 'API Gateway', url: 'https://api.example.com/health', expectedStatus: 200, timeoutMs: 5000 }, { name: 'Database Proxy', url: 'https://db-proxy.example.com/ping', expectedStatus: 200, timeoutMs: 3000 }, { name: 'CDN Origin', url: 'https://origin.example.com/alive', expectedStatus: 200, timeoutMs: 8000 }, ]; async function checkTarget(target: HealthTarget): Promise<boolean> { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), target.timeoutMs ?? 5000); const res = await fetch(target.url, { signal: controller.signal }); clearTimeout(timeout); return res.status === target.expectedStatus; } catch { return false; } } let previousFailures = new Set<string>(); async function runHealthChecks() { for (const target of targets) { const healthy = await checkTarget(target); if (!healthy && !previousFailures.has(target.name)) { // Baru gagal → kirim notifikasi await notificationStore.add({ type: 'error', title: `${target.name} Down`, message: `Health check gagal untuk ${target.name}. Endpoint: ${target.url}`, source: 'health-check', }); previousFailures.add(target.name); } else if (healthy && previousFailures.has(target.name)) { // Recovery → kirim info await notificationStore.add({ type: 'info', title: `${target.name} Recovered`, message: `${target.name} kembali normal.`, source: 'health-check', }); previousFailures.delete(target.name); } } } // Jalankan setiap 5 menit export function startHealthCron() { // Initial check saat startup runHealthChecks(); cron.schedule('*/5 * * * *', runHealthChecks); console.log('[HealthChecker] Cron started (every 5 minutes)'); }

Panggil startHealthCron() di layout root Next.js atau di custom server entry point.

Step 3: API Routes

Buat endpoint untuk frontend consume:

// app/api/notifications/route.ts import { notificationStore } from '@/lib/notification-store'; import { NextRequest } from 'next/server'; export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const format = searchParams.get('format'); // SSE endpoint untuk real-time if (format === 'sse') { const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { const unsubscribe = notificationStore.subscribe((notifications) => { const data = JSON.stringify({ count: notificationStore.getUnreadCount(), notifications: notifications.slice(0, 10), }); controller.enqueue(encoder.encode(`data: ${data}\n\n`)); }); // Cleanup saat client disconnect request.signal.addEventListener('abort', () => { unsubscribe(); controller.close(); }); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); } // Normal REST endpoint (fallback / polling) const limit = parseInt(searchParams.get('limit') ?? '50'); const offset = parseInt(searchParams.get('offset') ?? '0'); return Response.json({ count: notificationStore.getUnreadCount(), notifications: notificationStore.getAll(limit, offset), }); } export async function PATCH(request: NextRequest) { const body = await request.json(); await notificationStore.markRead(body.id); return Response.json({ success: true }); }

Step 4: Frontend Notification Bell

Komponen React yang subscribe ke SSE dan render bell dengan badge:

// components/notification-bell.tsx 'use client'; import { useEffect, useState, useRef } from 'react'; interface Notification { id: string; type: 'error' | 'warning' | 'info'; title: string; message: string; timestamp: number; read: boolean; } export function NotificationBell() { const [count, setCount] = useState(0); const [notifications, setNotifications] = useState<Notification[]>([]); const [open, setOpen] = useState(false); const panelRef = useRef<HTMLDivElement>(null); useEffect(() => { // Coba SSE dulu, fallback ke polling let cancelled = false; async function connect() { try { const evtSource = new EventSource('/api/notifications?format=sse'); evtSource.onmessage = (event) => { if (cancelled) return; const data = JSON.parse(event.data); setCount(data.count); setNotifications(data.notifications); }; evtSource.onerror = () => { evtSource.close(); // Fallback ke polling setiap 30 detik if (!cancelled) setInterval(poll, 30000); }; } catch { if (!cancelled) setInterval(poll, 30000); } } async function poll() { if (cancelled) return; const res = await fetch('/api/notifications'); const data = await res.json(); setCount(data.count); setNotifications(data.notifications); } connect(); return () => { cancelled = true; }; }, []); // Mark as read const markRead = async (id?: string) => { await fetch('/api/notifications', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }); }; // Close panel saat klik di luar useEffect(() => { const handler = (e: MouseEvent) => { if (panelRef.current && !panelRef.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); const typeIcon = (type: string) => { if (type === 'error') return '🔴'; if (type === 'warning') return '🟡'; return '🟢'; }; return ( <div className="relative" ref={panelRef}> <button onClick={() => setOpen(!open)} className="relative p-2 rounded-lg hover:bg-gray-100 transition" > 🔔 {count > 0 && ( <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"> {count > 99 ? '99+' : count} </span> )} </button> {open && ( <div className="absolute right-0 mt-2 w-96 max-h-[500px] overflow-y-auto bg-white shadow-xl rounded-xl border z-50"> <div className="p-3 border-b flex justify-between items-center"> <h3 className="font-semibold">Notifikasi</h3> {count > 0 && ( <button onClick={() => markRead()} className="text-xs text-blue-500 hover:underline" > Tandai semua dibaca </button> )} </div> {notifications.length === 0 ? ( <p className="p-4 text-gray-400 text-sm text-center">Tidak ada notifikasi</p> ) : ( notifications.map((n) => ( <div key={n.id} onClick={() => markRead(n.id)} className={`p-3 border-b cursor-pointer hover:bg-gray-50 transition ${ !n.read ? 'bg-blue-50/50' : '' }`} > <div className="flex items-start gap-2"> <span>{typeIcon(n.type)}</span> <div className="flex-1 min-w-0"> <p className="font-medium text-sm">{n.title}</p> <p className="text-xs text-gray-500 mt-0.5 truncate">{n.message}</p> <p className="text-xs text-gray-400 mt-1"> {new Date(n.timestamp).toLocaleString('id-ID')} </p> </div> {!n.read && <span className="w-2 h-2 bg-blue-500 rounded-full mt-1.5 shrink-0" />} </div> </div> )) )} </div> )} </div> ); }

Taruh <NotificationBell /> di header dashboard — done.

Step 5: Startup Hook

Di layout.tsx atau custom server, pastikan cron jalan:

// app/layout.tsx import { startHealthCron } from '@/lib/health-checker'; // Next.js 14+: pakai instrumentation hook // instrumentation.ts di root project export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { const { startHealthCron } = await import('@/lib/health-checker'); startHealthCron(); } }

Troubleshooting

MasalahPenyebabSolusi
Notifikasi nggak munculSSE koneksi dropFallback ke polling 30 detik
Duplicate notifikasiCron double-fireGuard dengan previousFailures set
Notifikasi hilang setelah restartPersist gagalCek write permission ke /data/
Memory leakSubscriber nggak di-unsubscribeCleanup di abort event

Hasil

  • ⚡ Notifikasi muncul < 1 detik setelah health check gagal
  • 💾 200 notifikasi terakhir persisten antar restart
  • 🔔 Badge counter auto-update via SSE
  • 🔄 Recovery notification otomatis saat service balik normal

Ada Pertanyaan? Yuk Ngobrol!

Butuh bantuan setup OpenClaw, konsultasi IT, atau mau diskusi project engineering? Book a call langsung — gratis.

Book a Call — Gratis

via Cal.com • WITA (UTC+8)

F

Zainul Fanani

Founder, Radian Group. Engineering & tech enthusiast.

Radian Group

Engineering Excellence Across Indonesia

Perusahaan

  • CV Radian Fokus Mandiri — Balikpapan
  • PT UNO Solusi Teknik — Balikpapan
  • PT Reka Formasi Elektrika — Jakarta
  • PT Raya Fokus Solusi — Sidoarjo
© 2026 Radian Group. All rights reserved.