DEV Community

Cover image for Real-Time Notifications in a MERN App — explained like texting your nerd bestie
Vaibhav Singh
Vaibhav Singh

Posted on

Real-Time Notifications in a MERN App — explained like texting your nerd bestie

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”:

  1. Realtime lane → server emits Socket.IO events to the right users/roles.
  2. 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/
Enter fullscreen mode Exit fullscreen mode

🛠️ 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
Enter fullscreen mode Exit fullscreen mode

Add scripts to package.json:

{
  "name": "server",
  "type": "module",
  "scripts": {
    "dev": "nodemon server.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create .env:

PORT=5000
MONGO_URI=mongodb://localhost:27017/mern_notify
JWT_SECRET=dev_super_secret_change_me
CORS_ORIGINS=http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

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}`))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Start the server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

🎨 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
Enter fullscreen mode Exit fullscreen mode

Add .env (Vite expects VITE_ prefix):

VITE_API_URL=http://localhost:5000/api
VITE_SOCKET_URL=http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Run the client:

npm run dev
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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.