This content originally appeared on DEV Community and was authored by Vaibhav Singh
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.
This content originally appeared on DEV Community and was authored by Vaibhav Singh