You want that sweet little bell to light up the moment payments, bookings, or admin broadcasts happen — and you want the list to still be there after refresh. This guide takes you from nothing to a working MERN stack with:
- Socket.IO for instant in‑app updates
- MongoDB for persistence (history + unread)
- REST endpoints to fetch + mark read
- React hook + UI to display the dropdown
Let’s ship. 🚀
🧭 What we’re building
Two lanes make notifications feel “right”:
- Realtime lane → server emits Socket.IO events to the right users/roles.
- Persistence lane → server stores notifications in Mongo so they survive refresh/offline.
The client:
- fetches last 50 notifications on mount,
- listens for socket events and prepends new ones,
- marks an item as read when clicked.
Result: real-time + durable notifications.
📦 Prereqs
- Node 18+
- npm or pnpm
- A MongoDB connection (local or Atlas).
I’ll assume mongodb://localhost:27017/mern_notify
locally.
🗂️ Project layout
We’ll create two folders:
mern-realtime-notify/
server/
client/
🛠️ Server (Express + Socket.IO + Mongo)
1) Init + install
mkdir -p mern-realtime-notify/server && cd mern-realtime-notify/server
npm init -y
npm i express cors dotenv mongoose socket.io jsonwebtoken bcrypt
npm i -D nodemon
Add scripts to package.json
:
{
"name": "server",
"type": "module",
"scripts": {
"dev": "nodemon server.js"
}
}
Create .env:
PORT=5000
MONGO_URI=mongodb://localhost:27017/mern_notify
JWT_SECRET=dev_super_secret_change_me
CORS_ORIGINS=http://localhost:5173
2) Basic server with Socket.IO auth
server.js
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import mongoose from 'mongoose'
import { createServer } from 'http'
import { Server } from 'socket.io'
import jwt from 'jsonwebtoken'
dotenv.config()
const app = express()
const server = createServer(app)
const allowed = (process.env.CORS_ORIGINS || '').split(',').filter(Boolean)
const io = new Server(server, {
cors: {
origin: allowed.length ? allowed : '*',
credentials: true,
},
transports: ['websocket', 'polling'],
})
app.use(cors({ origin: allowed.length ? allowed : '*', credentials: true }))
app.use(express.json())
// connect mongo
await mongoose.connect(process.env.MONGO_URI)
console.log('Mongo connected')
// ===== MODELS =====
import Notification from './src/models/Notification.js'
import User from './src/models/User.js'
// ===== ROUTES =====
import authRouter from './src/routes/auth.js'
import notifRouter from './src/routes/notifications.js'
// expose io to routes
app.use((req, _res, next) => { req.io = io; next() })
app.use('/api/auth', authRouter)
app.use('/api/notifications', notifRouter)
app.get('/api/health', (_req, res) => res.json({ ok: true }))
// ===== SOCKET AUTH =====
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth?.token
if (!token) return next(new Error('Missing token'))
const decoded = jwt.verify(token, process.env.JWT_SECRET)
const user = await User.findById(decoded.userId).select('-password')
if (!user) return next(new Error('User not found'))
socket.user = user
next()
} catch (e) {
next(new Error('Auth failed: ' + e.message))
}
})
io.on('connection', (socket) => {
const user = socket.user
const userRoom = `user_${user._id}`
const roleRoom = `role_${user.role}`
socket.join(userRoom)
socket.join(roleRoom)
console.log(`Socket connected: ${user.email} -> rooms: ${userRoom}, ${roleRoom}`)
socket.on('disconnect', (reason) => {
console.log('Socket disconnected', reason)
})
})
const PORT = process.env.PORT || 5000
server.listen(PORT, () => console.log(`Server listening on ${PORT}`))
3) Models
src/models/User.js (super minimal; don’t use in prod without tweaks)
import mongoose from 'mongoose'
import bcrypt from 'bcrypt'
const userSchema = new mongoose.Schema({
email: { type: String, unique: true },
password: String,
role: { type: String, default: 'patient' } // 'patient' | 'doctor' | 'admin'
})
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next()
this.password = await bcrypt.hash(this.password, 10)
next()
})
userSchema.methods.compare = function(pw) { return bcrypt.compare(pw, this.password) }
export default mongoose.model('User', userSchema)
src/models/Notification.js
import mongoose from 'mongoose'
const notificationSchema = new mongoose.Schema(
{
userId: { type: mongoose.Schema.Types.ObjectId, index: true },
type: { type: String, required: true }, // e.g., 'payment_success'
title: String,
message: String,
meta: Object,
readAt: Date,
},
{ timestamps: { createdAt: true, updatedAt: true } }
)
export default mongoose.model('Notification', notificationSchema)
4) Auth routes (login/register + JWT)
src/routes/auth.js
import express from 'express'
import jwt from 'jsonwebtoken'
import User from '../models/User.js'
const router = express.Router()
router.post('/register', async (req, res) => {
const { email, password, role = 'patient' } = req.body
const exists = await User.findOne({ email })
if (exists) return res.status(400).json({ success: false, message: 'Email in use' })
const user = await User.create({ email, password, role })
res.json({ success: true, user: { id: user._id, email: user.email, role: user.role } })
})
router.post('/login', async (req, res) => {
const { email, password } = req.body
const user = await User.findOne({ email })
if (!user || !(await user.compare(password))) {
return res.status(401).json({ success: false, message: 'Invalid credentials' })
}
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: '7d' })
res.json({ success: true, token, user: { id: user._id, email: user.email, role: user.role } })
})
export default router
5) Notifications routes (fetch + markRead + test)
src/routes/notifications.js
import express from 'express'
import jwt from 'jsonwebtoken'
import Notification from '../models/Notification.js'
const router = express.Router()
// simple auth middleware reading Bearer token
function authenticate(req, res, next) {
try {
const h = req.headers.authorization || ''
const token = h.startsWith('Bearer ') ? h.slice(7) : null
if (!token) return res.status(401).json({ success: false, message: 'No token' })
const decoded = jwt.verify(token, process.env.JWT_SECRET)
req.user = { _id: decoded.userId }
next()
} catch (e) {
res.status(401).json({ success: false, message: 'Invalid token' })
}
}
// GET: latest 50
router.get('/', authenticate, async (req, res) => {
const list = await Notification.find({ userId: req.user._id }).sort({ createdAt: -1 }).limit(50).lean()
res.json({ success: true, notifications: list })
})
// POST: mark read
router.post('/read', authenticate, async (req, res) => {
const { id } = req.body
await Notification.updateOne({ _id: id, userId: req.user._id }, { $set: { readAt: new Date() } })
res.json({ success: true })
})
// POST: test -> store + emit
router.post('/test', authenticate, async (req, res) => {
const { message = 'This is a test', type = 'info', title = 'Test notification' } = req.body
const doc = await Notification.create({ userId: req.user._id, type, title, message })
req.io.to(`user_${req.user._id}`).emit('notification', {
_id: doc._id, type, title, message, createdAt: doc.createdAt, readAt: null
})
res.json({ success: true })
})
export default router
Start the server:
npm run dev
🎨 Client (React + Vite)
1) Init + install
cd ../
npm create vite@latest client -- --template react
cd client
npm i socket.io-client axios react-hot-toast
Add .env (Vite expects VITE_
prefix):
VITE_API_URL=http://localhost:5000/api
VITE_SOCKET_URL=http://localhost:5000
2) Minimal API helper
src/lib/api.js
import axios from 'axios'
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL })
export function setAuth(token) {
api.defaults.headers.common['Authorization'] = token ? `Bearer ${token}` : undefined
}
export default api
3) Auth context (store token + user)
src/contexts/AuthContext.jsx
import { createContext, useContext, useState } from 'react'
import api, { setAuth } from '../lib/api'
const AuthCtx = createContext(null)
export const useAuth = () => useContext(AuthCtx)
export default function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [token, setToken] = useState(null)
const login = async (email, password) => {
const res = await api.post('/auth/login', { email, password })
if (res.data.success) {
setToken(res.data.token)
setAuth(res.data.token)
setUser(res.data.user)
}
return res.data
}
const register = async (email, password, role='patient') => {
const res = await api.post('/auth/register', { email, password, role })
return res.data
}
const logout = () => {
setToken(null); setUser(null); setAuth(null)
}
return (
<AuthCtx.Provider value={{ user, token, login, register, logout }}>
{children}
</AuthCtx.Provider>
)
}
4) Socket context (auth handshake)
src/contexts/SocketContext.jsx
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import { io } from 'socket.io-client'
import { useAuth } from './AuthContext'
const SocketCtx = createContext(null)
export const useSocket = () => useContext(SocketCtx)
export default function SocketProvider({ children }) {
const { token } = useAuth()
const [isConnected, setIsConnected] = useState(false)
const socketRef = useRef(null)
useEffect(() => {
if (!token) return
const socket = io(import.meta.env.VITE_SOCKET_URL, {
auth: { token },
transports: ['websocket', 'polling'],
})
socketRef.current = socket
socket.on('connect', () => setIsConnected(true))
socket.on('disconnect', () => setIsConnected(false))
return () => socket.disconnect()
}, [token])
return (
<SocketCtx.Provider value={{ socket: socketRef.current, isConnected }}>
{children}
</SocketCtx.Provider>
)
}
5) Notifications hook (fetch + live + markRead)
src/hooks/useNotifications.js
import { useEffect, useMemo, useState } from 'react'
import api from '../lib/api'
import { useSocket } from '../contexts/SocketContext'
const EVENTS = [
'notification', // generic catch-all we emit in /notifications/test
// add more if your server emits: 'payment_success', 'new_appointment', etc.
]
export function useNotifications() {
const { socket } = useSocket()
const [items, setItems] = useState([])
useEffect(() => {
let dead = false
;(async () => {
try {
const res = await api.get('/notifications')
if (!dead && res.data?.success) setItems(res.data.notifications)
} catch {}
})()
return () => { dead = true }
}, [])
useEffect(() => {
if (!socket) return
const on = (type) => (p) => setItems(prev => [normalize(type, p), ...prev])
EVENTS.forEach(evt => socket.on(evt, on(evt)))
return () => { EVENTS.forEach(evt => socket.off(evt)) }
}, [socket])
const unreadCount = useMemo(() => items.filter(n => !n.readAt).length, [items])
const markRead = async (id) => {
setItems(prev => prev.map(n => (n._id === id ? { ...n, readAt: new Date().toISOString() } : n)))
try { await api.post('/notifications/read', { id }) } catch {}
}
return { items, unreadCount, markRead }
}
function normalize(type, p) {
return {
_id: p._id || (Math.random().toString(36).slice(2) + Date.now().toString(36)),
type,
title: p.title || 'Notification',
message: p.message || '',
createdAt: p.createdAt || p.timestamp || new Date().toISOString(),
readAt: p.readAt || null,
...p,
}
}
6) Tiny UI (login + bell)
src/App.jsx
import { useState } from 'react'
import AuthProvider, { useAuth } from './contexts/AuthContext'
import SocketProvider from './contexts/SocketContext'
import { useNotifications } from './hooks/useNotifications'
function Login() {
const { login, register } = useAuth()
const [email, setEmail] = useState('demo@example.com')
const [password, setPassword] = useState('secret')
return (
<div style={{ display:'flex', gap: 8, marginBottom: 16 }}>
<input value={email} onChange={e=>setEmail(e.target.value)} placeholder="email" />
<input value={password} onChange={e=>setPassword(e.target.value)} placeholder="password" type="password" />
<button onClick={() => register(email, password)}>Register</button>
<button onClick={() => login(email, password)}>Login</button>
</div>
)
}
function Bell() {
const { items, unreadCount, markRead } = useNotifications()
return (
<div>
<button>🔔 {unreadCount > 0 ? `(${unreadCount})` : ''}</button>
<div style={{ border:'1px solid #ddd', padding: 8, width: 360, marginTop: 8 }}>
{items.map(n => (
<div key={n._id} onClick={()=>markRead(n._id)} style={{ padding: 8, borderBottom:'1px solid #eee', cursor:'pointer' }}>
<div style={{ fontWeight:'bold' }}>{n.title}</div>
<div style={{ fontSize: 12, opacity:.7 }}>{new Date(n.createdAt).toLocaleString()}</div>
{n.message && <div style={{ fontSize: 13, marginTop: 4 }}>{n.message}</div>}
</div>
))}
</div>
</div>
)
}
export default function App() {
return (
<AuthProvider>
<SocketProvider>
<div style={{ padding: 24, fontFamily: 'sans-serif' }}>
<h1>MERN Real-time Notifications</h1>
<Login />
<Bell />
</div>
</SocketProvider>
</AuthProvider>
)
}
Run the client:
npm run dev
Open http://localhost:5173/. Register, then login.
🧪 Test it (Does it ding?)
With the client logged in, hit the test endpoint from a terminal:
curl -X POST http://localhost:5000/api/notifications/test \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_HERE" \
-d '{"message":"Hello from test!","type":"info"}'
You should see a new row appear under the bell instantly. Click it → it’s marked read. Refresh → it’s still there. ✅
🧯 Common gotchas
- CORS: Allow your client origin on both Express and Socket.IO CORS.
-
Auth handshake: Send
auth: { token }
when connecting the socket (we did). -
Cleanup: Always
off()
socket listeners on unmount (the hook does). - Reverse proxy: Enable WebSocket upgrades on Nginx/Cloudflare/etc.
-
Security: On mark-read, always filter by
{ _id, userId }
(we do).
🧰 Extras (when you want more)
-
Role broadcasts: Server joins users to
role_admin
,role_doctor
,role_patient
. Emit to a role room. - Scheduled reminders: Add Redis + BullMQ → schedule “appointment in 1h” jobs, emit at the right time.
- Web Push/FCM: For OS-level notifications when the tab is closed.
- Hosted websockets: Pusher/Ably/PubNub if you don’t want to run Socket.IO infra.
- Emails/SMS: SendGrid/SES and Twilio for critical backups.
🏁 Recap
- Mongo stores notifications.
- Socket.IO delivers them in real-time.
- React hook keeps UI in sync and handles “mark read”.
This pattern is tiny, fast, and rock-solid. You can paste it into any MERN app and be “the person who made the bell work” by lunch. 🔔✨
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.