Building a Real-Time Notification System with MERN Stack and Socket.io: A Step-by-Step Guide

Building a Real-Time Notification System with MERN Stack and Socket.io: A Step-by-Step Guide

Implementing Real-Time Features in Your MERN Stack Application Using WebSockets and JWT Authentication

Introduction

Imagine you're scrolling through your favorite social media app when, suddenly, a notification pops up telling you that your friend just liked your latest post. It’s instant, it’s engaging, and it keeps you hooked. Real-time notifications are everywhere these days, from social media alerts to breaking news updates, and they play a huge role in how we interact with our apps.

But have you ever wondered how these notifications actually work behind the scenes? How do apps manage to send you updates the moment something happens? The magic lies in a combination of technologies that allow real-time communication between the server and your browser.

In this blog, I’ll walk you through the process of building your own real-time notification system using the MERN stack—MongoDB, Express, React, and Node.js. By the end of this guide, you’ll not only have a functional notification system but also a solid understanding of how to implement real-time features in your own applications.

Whether you’re building the next big social media platform or just want to add some flair to your existing app, real-time notifications are a powerful tool to keep your users engaged. Let’s dive in and start building!

Setting Up the MERN Stack

Before we dive into building our real-time notification system, we need to set up the foundational MERN stack application. If you're new to MERN or just need a refresher, I’ve got you covered! In a previous article, I walked through the step-by-step process of setting up a full-stack application with React, Node.js, and Express.

You can follow that guide here to get your application up and running quickly. The accompanying code is available on GitHub, so you can clone the repository and get started right away.

In summary, the article covers:

  • Initializing a React project with Vite for a faster and more optimized development experience.

  • Setting up a backend REST server with Node and Express.

  • Additionally, configuring Styled Components and Twin Macro to leverage Tailwind CSS for styling your application.

In the following sections, where we put the code together for the notification system, I have used the same starter code from the previous article.

However, in this article, we'll be taking it a step further by setting up MongoDB, which wasn’t covered in the previous guide. MongoDB will be our database of choice, allowing us to store and manage notifications efficiently. Below are the steps to set up MongoDB using MongoDB Atlas.

Setting Up MongoDB with MongoDB Atlas

MongoDB Atlas is a cloud-based service that provides a fully managed MongoDB cluster. Here’s how you can set it up:

Step 1: Create a MongoDB Atlas Account

  1. Visit MongoDB Atlas and sign up for a free account.

  2. Once you’ve signed up and logged in, click on the "Start Free" button to create your first cluster.

Step 2: Create a Free Cluster

  1. On the Atlas dashboard, click on the "Build a Cluster" button.

  2. Choose the cloud provider and region for your cluster. For free-tier clusters, you can select the default settings.

  3. Click "Create Cluster". This process may take a few minutes.

Step 3: Whitelist Your IP Address

  1. After your cluster is created, you’ll need to whitelist your IP address to connect to the cluster.

  2. Go to the "Network Access" tab on the left sidebar.

  3. Click on "Add IP Address".

  4. To allow access from your current IP address, click "Add Current IP Address". You can also use "0.0.0.0/0" to allow access from any IP address (not recommended for production).

  5. Click "Confirm" to save your settings.

Step 4: Create a Database User

  1. Go to the "Database Access" tab on the left sidebar.

  2. Click on "Add New Database User".

  3. Choose a username and password for your database user.

  4. Under "Database User Privileges", select "Atlas Admin" for full access.

  5. Click "Add User" to create the user.

Step 5: Get Your Connection String

  1. Return to the "Clusters" view and click on "Connect" for your cluster.

  2. In the popup window, select "Connect Your Application".

  3. Choose "Node.js" as your driver and select the latest version.

  4. You’ll be provided with a connection string. It will look something like this:

     mongodb+srv://<username>:<password>@cluster0.mongodb.net/?retryWrites=true&w=majority
    
  5. Replace <username> and <password> with the username and password you created earlier.

  6. Copy this connection string, as you’ll need it to connect your application to MongoDB.

Once you've completed these steps, your MongoDB Atlas cluster will be ready to use with your MERN stack application. We’ll use this connection string in our Node.js backend to connect to the database and store our notifications.

Implementing the backend

In this section, we’ll build out the backend for our real-time notification system using Node.js, Express, MongoDB, and Socket.io. The backend will handle authentication, manage notifications, and establish WebSocket connections for real-time updates.

Let’s go step by step through the code provided to set everything up.

Setting Up the Project Structure

First, let's organize the project. Make sure you have Node.js and npm installed on your machine.

  1. Initialize a new Node.js project:

     mkdir notification-system
     cd notification-system
     npm init -y
    
  2. Install the necessary dependencies:

     npm install express mongoose body-parser socket.io jsonwebtoken dotenv cors
    
    • express: Web framework for building APIs.

    • mongoose: ODM for MongoDB to manage database interactions.

    • body-parser: Middleware to parse incoming request bodies in JSON format.

    • socket.io: Library for real-time WebSocket connections.

    • jsonwebtoken: For handling JWT (JSON Web Tokens) authentication.

    • dotenv: For loading environment variables from a .env file.

    • cors: Middleware to enable Cross-Origin Resource Sharing.

  3. Create the following files and directories:

    • index.js: The main entry point of your application.

    • middleware.js: To handle authentication middleware for both HTTP requests and WebSocket connections.

    • .env: To store environment variables like JWT secret and MongoDB URI.

Setting Up JWT Authentication

We'll use JWT (JSON Web Tokens) for authenticating both HTTP requests and WebSocket connections.

  1. Create middleware.js for handling JWT authentication:

     const jwt = require('jsonwebtoken');
    
     // Middleware to authenticate JWT for HTTP requests
     const authenticateToken = (req, res, next) => {
       const authHeader = req.headers['authorization'];
       const token = authHeader && authHeader.split(' ')[1];
    
       if (!token) return res.sendStatus(401);
    
       jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
         if (err) return res.sendStatus(403);
         req.user = user;
         next();
       });
     };
    
     // Middleware to authenticate JWT for WebSocket connections
     const authenticateSocket = (socket, next) => {
       const token = socket.handshake.auth.token;
    
       if (!token) return next(new Error('Authentication error'));
    
       jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
         if (err) return next(new Error('Authentication error'));
         socket.user = user;
         next();
       });
     };
    
     module.exports = { authenticateToken, authenticateSocket };
    

    Explanation:

    • authenticateToken: This middleware authenticates JWT for standard HTTP requests. It extracts the token from the Authorization header, verifies it, and attaches the user information to the request object.

    • authenticateSocket: Similar to the HTTP middleware, this function authenticates JWTs during the WebSocket handshake. It verifies the token from the auth object in the socket handshake and attaches the user data to the socket object.

  2. Create a .env file to store sensitive information:

     JWT_SECRET=your_jwt_secret_key
     MONGO_URI=your_mongo_uri
    

    Replace your_jwt_secret_key with a secret key of your choice and your_mongo_uri with the connection string from MongoDB Atlas that you set up earlier.

Implementing the Main Server Logic

Next, we’ll set up the Express server, connect to MongoDB, and implement the WebSocket logic.

  1. Create index.js and add the following code:

     const express = require('express');
     const mongoose = require('mongoose');
     const bodyParser = require('body-parser');
     const { Server } = require('socket.io');
     const jwt = require('jsonwebtoken');
     const { authenticateToken, authenticateSocket } = require('./middleware');
     const cors = require('cors');
    
     const app = express();
     app.use(cors());
     app.use(bodyParser.json());
    
     require('dotenv').config();
    
     // Connect to MongoDB
     mongoose.connect(process.env.MONGO_URI, {
       useNewUrlParser: true,
       useUnifiedTopology: true,
     });
    
     // Create a notification model
     const notificationSchema = new mongoose.Schema({
       userId: String,
       message: String,
       timestamp: { type: Date, default: Date.now },
       isRead: { type: Boolean, default: false },
       type: String,
     });
    
     const Notification = mongoose.model('Notification', notificationSchema);
    
     // Start the Express server
     const PORT = 4000;
     const server = app.listen(PORT, () => {
       console.log(`Server is running on port ${PORT}`);
     });
    
     // Set up Socket.IO with the existing Express server
     const io = new Server(server, {
       cors: {
         origin: '*', // Allow CORS for all origins
       },
     });
    
     // Use authentication middleware for WebSocket connections
     io.use(authenticateSocket);
    
     // Handle WebSocket connections
     io.on('connection', (socket) => {
       console.log('A user connected');
    
       // Extract userId from socket authentication and join a room
       const userId = socket.user.userId;
    
       // Join the user to a room with their userId
       socket.join(userId);
    
       // Handle disconnection
       socket.on('disconnect', () => {
         console.log('User disconnected');
       });
     });
    
     // API endpoint to get notifications
     app.get('/notifications', authenticateToken, async (req, res) => {
       try {
         const notifications = await Notification.find({ userId: req.user.userId });
         res.json(notifications);
       } catch (error) {
         res.status(500).json({ error: 'Failed to retrieve notifications' });
       }
     });
    
     // API endpoint to create a new notification
     app.post('/notifications', authenticateToken, async (req, res) => {
       try {
         const { userId, message, type } = req.body;
         const notification = new Notification({ userId, message, type });
         await notification.save();
    
         // Emit the notification to the specific user's room
         io.to(userId).emit('notification', notification);
    
         res.status(201).json(notification);
       } catch (error) {
         res.status(500).json({ error: 'Failed to create notification' });
       }
     });
    
     // Authentication endpoint to generate JWT
     app.post('/login', (req, res) => {
       const { username, password } = req.body;
    
       // Replace with your user authentication logic
       if (username === 'John' && password === 'password') {
         const user = { userId: 'user1', username };
         const accessToken = jwt.sign(user, process.env.JWT_SECRET, { expiresIn: '1h' });
         res.json({ accessToken });
       } else if (username === 'Jane' && password === 'password2') {
         const user = { userId: 'user2', username };
         const accessToken = jwt.sign(user, process.env.JWT_SECRET, { expiresIn: '1h' });
         res.json({ accessToken });
       } else {
         res.status(401).json({ error: 'Invalid credentials' });
       }
     });
    

    Explanation:

    This file serves as the main entry point for your Node.js application. It sets up the Express server, connects to MongoDB, handles WebSocket connections with Socket.IO, and provides the necessary API endpoints to interact with the notification system.

    Importing Required Modules:

     const express = require('express');
     const mongoose = require('mongoose');
     const bodyParser = require('body-parser');
     const { Server } = require('socket.io');
     const jwt = require('jsonwebtoken');
     const { authenticateToken, authenticateSocket } = require('./middleware');
     const cors = require('cors');
    
    • express: The core web framework for building the REST API.

    • mongoose: An ODM (Object Data Modeling) library for MongoDB and Node.js. It manages the connection to MongoDB and provides a schema-based solution to model your application data.

    • body-parser: Middleware to parse incoming JSON request bodies, making it easier to work with the data sent from the client.

    • socket.io: A library for enabling real-time, bidirectional communication between the server and the client over WebSockets.

    • jsonwebtoken: Used to handle JWT (JSON Web Token) for user authentication.

    • cors: Middleware to enable Cross-Origin Resource Sharing, allowing the API to be accessible from different origins (domains).

    • authenticateToken, authenticateSocket: Custom middleware functions for authenticating HTTP requests and WebSocket connections, respectively.

Configuring the Express Application:

    const app = express();
    app.use(cors());
    app.use(bodyParser.json());

    require('dotenv').config();
  • app.use(cors()): This enables CORS for all origins, allowing clients from any domain to make requests to your API. This is particularly important in development when your frontend and backend might run on different ports.

  • app.use(bodyParser.json()): This middleware automatically parses incoming JSON data and attaches it to the req.body object, simplifying the handling of JSON payloads in your routes.

  • require('dotenv').config(): This loads environment variables from a .env file into process.env, allowing you to securely manage sensitive information like your JWT secret and MongoDB URI.

Connecting to MongoDB:

    mongoose.connect(process.env.MONGO_URI, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
  • mongoose.connect: This establishes a connection to your MongoDB database using the URI stored in the .env file. The useNewUrlParser and useUnifiedTopology options ensure that you're using the latest MongoDB driver features and connection handling.

Defining the Notification Schema and Model:

    const notificationSchema = new mongoose.Schema({
      userId: String,
      message: String,
      timestamp: { type: Date, default: Date.now },
      isRead: { type: Boolean, default: false },
      type: String,
    });

    const Notification = mongoose.model('Notification', notificationSchema);
  • notificationSchema: This defines the structure of a notification document in MongoDB. It includes fields like userId, message, timestamp, isRead, and type. The timestamp field automatically defaults to the current date and time.

  • Notification: This is a Mongoose model based on the notificationSchema. It represents the collection of notifications in your MongoDB database and provides an interface to interact with these documents (e.g., creating, querying, updating).

Starting the Express Server:

    const PORT = 4000;
    const server = app.listen(PORT, () => {
      console.log(`Server is running on port ${PORT}`);
    });
  • app.listen: This starts the Express server on the specified PORT (4000 in this case) and begins listening for incoming HTTP requests. The callback function logs a message to the console once the server is running.

Setting Up Socket.IO for Real-Time Communication:

    const io = new Server(server, {
      cors: {
        origin: '*', // Allow CORS for all origins
      },
    });

    // Use authentication middleware for WebSocket connections
    io.use(authenticateSocket);
  • new Server(server, { cors: { origin: '*' }}): This initializes a Socket.IO server, attaching it to the existing Express server. The CORS configuration here allows WebSocket connections from any origin.

  • io.use(authenticateSocket): This applies the authenticateSocket middleware to all WebSocket connections. Before a client can establish a connection, the server verifies the client's JWT to ensure they are authenticated.

Handling WebSocket Connections:

    io.on('connection', (socket) => {
      console.log('A user connected');

      // Extract userId from socket authentication and join a room
      const userId = socket.user.userId;

      // Join the user to a room with their userId
      socket.join(userId);

      // Handle disconnection
      socket.on('disconnect', () => {
        console.log('User disconnected');
      });
    });
  • io.on('connection'): This event listener is triggered whenever a new client establishes a WebSocket connection with the server. The connected client is represented by the socket object.

  • const userId = socket.user.userId; The authenticateSocket middleware attaches the authenticated user data to the socket object. Here, the userId is extracted from this data.

  • socket.join(userId): This line adds the client to a "room" identified by their userId. This enables you to emit notifications specifically to this user.

  • socket.on('disconnect'): This event listener is triggered when a client disconnects. It logs a message to the console to indicate the disconnection.

Defining API Endpoints:

Fetching Notifications:

    app.get('/notifications', authenticateToken, async (req, res) => {
      try {
        const notifications = await Notification.find({ userId: req.user.userId });
        res.json(notifications);
      } catch (error) {
        res.status(500).json({ error: 'Failed to retrieve notifications' });
      }
    });
  • GET /notifications: This endpoint retrieves all notifications for the authenticated user. The authenticateToken middleware ensures that only authenticated users can access this route.

  • Notification.find({ userId: req.user.userId }): This query fetches all notifications from the database that match the userId of the authenticated user.

  • res.json(notifications): The server responds with the list of notifications in JSON format.

Creating a New Notification:

    app.post('/notifications', authenticateToken, async (req, res) => {
      try {
        const { userId, message, type } = req.body;
        const notification = new Notification({ userId, message, type });
        await notification.save();

        // Emit the notification to the specific user's room
        io.to(userId).emit('notification', notification);

        res.status(201).json(notification);
      } catch (error) {
        res.status(500).json({ error: 'Failed to create notification' });
      }
    });
  • POST /notifications: This endpoint allows an authenticated user to create a new notification.

  • const notification = new Notification({ userId, message, type }); This creates a new instance of the Notification model with the data provided in the request body (userId, message, type).

  • await notification.save(); The notification is saved to the MongoDB database.

  • io.to(userId).emit('notification', notification); The newly created notification is emitted in real-time to the specific user’s room via WebSocket.

User Authentication Endpoint:

    app.post('/login', (req, res) => {
      const { username, password } = req.body;

      // Replace with your user authentication logic
      if (username === 'John' && password === 'password') {
        const user = { userId: 'user1', username };
        const accessToken = jwt.sign(user, process.env.JWT_SECRET, { expiresIn: '1h' });
        res.json({ accessToken });
      } else if (username === 'Jane' && password === 'password2') {
        const user = { userId: 'user2', username };
        const accessToken = jwt.sign(user, process.env.JWT_SECRET, { expiresIn: '1h' });
        res.json({ accessToken });
      } else {
        res.status(401).json({ error: 'Invalid credentials' });
      }
    });
  • POST /login: This endpoint simulates a basic login process, validating the username and password provided in the request.

  • jwt.sign(user, process.env.JWT_SECRET, { expiresIn: '1h' }): If the credentials are valid, a JWT is generated with the user’s information (userId and username) as the payload. The token is signed using the secret key from the .env file and is set to expire in 1 hour.

  • res.json({ accessToken }); The JWT is returned to the client, who will use it to authenticate future requests and WebSocket connections.

Building the frontend

In this section, we'll build the frontend of our real-time notification system using React. If you haven't set up your React application yet, you can refer to this article, where I provide a detailed guide on how to get started with React, including initial configuration with Vite, Styled Components, and Twin Macro for Tailwind CSS.

Now, let’s walk through the App.jsx file step by step to understand how it integrates with the backend and handles real-time notifications.

Overview of App.jsx

import styled from 'styled-components';
import tw from 'twin.macro';
import vite from '/vite.svg';
import React, { useEffect, useState } from 'react';
import io from 'socket.io-client';

const token = localStorage.getItem('accessToken'); // Retrieve token from storage

const socket = io('http://localhost:4000', {
  auth: {
    token,
  },
});

const App = () => {
  const [notifications, setNotifications] = useState([]);

  useEffect(() => {
    const fetchNotifications = async () => {
      const token = localStorage.getItem('accessToken'); // Retrieve token from storage

      const response = await fetch('http://localhost:4000/notifications', {
        method: 'GET',
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      const data = await response.json();
      console.log('data', data);
      setNotifications(data);
    };

    // Fetch initial notifications from the server
    fetchNotifications();

    // Listen for new notifications
    socket.on('notification', (notification) => {
      setNotifications((prevNotifications) => [
        notification,
        ...prevNotifications,
      ]);
    });

    // Cleanup on component unmount
    return () => {
      socket.off('notification');
    };
  }, []);

  return (
    <ApplicationContainer>
      <SectionContainer>
        <Image src={vite} alt='Vite Logo' />
        <CenteredText>
          <H2>Demonstration</H2>
          <Title>Notification-system</Title>
        </CenteredText>
        <NotificationSection>
          <NotificationHeader>Notifications</NotificationHeader>
          <NotificationList>
            {notifications.map((notification) => (
              <NotificationItem key={notification._id}>
                {notification.message}
              </NotificationItem>
            ))}
          </NotificationList>
        </NotificationSection>
      </SectionContainer>
    </ApplicationContainer>
  );
};

export default App;

const ApplicationContainer = tw.div`bg-gradient-to-r from-indigo-500 to-purple-500 h-screen flex items-center justify-center`;

const SectionContainer = tw.div`bg-white shadow-lg rounded-lg p-8 max-w-3xl mx-auto`;

const CenteredText = tw.div`text-center`;

const H2 = tw.h2`font-bold text-lg text-indigo-600 uppercase tracking-wider mb-2`;

const Title = tw.p`text-gray-800 text-3xl sm:text-4xl font-extrabold leading-tight`;

const Image = tw.img`h-20 w-auto mx-auto my-6`;

const NotificationSection = tw.div`mt-10`;

const NotificationHeader = tw.h3`text-xl font-semibold text-gray-700 mb-4`;

const NotificationList = tw.ul`space-y-3`;

const NotificationItem = styled.li`
  ${tw`bg-indigo-100 text-indigo-700 p-4 rounded-lg shadow-md transition-all duration-300 ease-in-out`}
  &:hover {
    ${tw`bg-indigo-200`}
  }
`;

Step-by-Step Explanation

Importing Required Modules and Assets

import styled from 'styled-components';
import tw from 'twin.macro';
import vite from '/vite.svg';
import React, { useEffect, useState } from 'react';
import io from 'socket.io-client';
  • styled-components & twin.macro: These libraries are used to style React components using a mix of Styled Components and Tailwind CSS. tw is a utility from Twin Macro that allows you to write Tailwind CSS classes within Styled Components.

  • vite: The Vite logo is imported as an image asset, which will be displayed in the application.

  • React: The core React library is imported along with useEffect and useState hooks to manage component state and side effects.

  • socket.io-client: This library enables WebSocket communication between the frontend and backend.

Setting Up the WebSocket Connection

const token = localStorage.getItem('accessToken'); // Retrieve token from storage

const socket = io('http://localhost:4000', {
  auth: {
    token,
  },
});
  • token: The JWT token stored in localStorage is retrieved to authenticate the WebSocket connection. This token was likely obtained during the user login process.

  • socket: A WebSocket connection is established to the backend server at http://localhost:4000. The auth object contains the token, which is passed to the backend for authentication during the WebSocket handshake.

Creating the Main Component

const App = () => {
  const [notifications, setNotifications] = useState([]);
  • useState: Initializes the notifications state as an empty array. This state will hold the list of notifications received from the server and will be updated as new notifications are received.

Fetching Initial Notifications

useEffect(() => {
  const fetchNotifications = async () => {
    const token = localStorage.getItem('accessToken'); // Retrieve token from storage

    const response = await fetch('http://localhost:4000/notifications', {
      method: 'GET',
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });

    const data = await response.json();
    console.log('data', data);
    setNotifications(data);
  };

  // Fetch initial notifications from the server
  fetchNotifications();
  • useEffect: The useEffect hook is used to run side effects—in this case, fetching initial notifications from the server when the component mounts.

  • fetchNotifications: This asynchronous function sends a GET request to the /notifications endpoint of the backend to retrieve the notifications for the authenticated user. The JWT token is included in the Authorization header as Bearer <token>.

  • setNotifications: The retrieved notifications are stored in the notifications state, which will be used to render the notifications in the UI.

Listening for New Notifications

// Listen for new notifications
  socket.on('notification', (notification) => {
    setNotifications((prevNotifications) => [
      notification,
      ...prevNotifications,
    ]);
  });

  // Cleanup on component unmount
  return () => {
    socket.off('notification');
  };
}, []);
  • socket.on('notification'): This listens for notification events sent by the backend through the WebSocket connection. When a new notification is received, it is added to the top of the notifications state array using the setNotifications function.

  • Cleanup: The return statement in useEffect ensures that the event listener is removed when the component unmounts, preventing memory leaks.

Rendering the UI

return (
  <ApplicationContainer>
    <SectionContainer>
      <Image src={vite} alt='Vite Logo' />
      <CenteredText>
        <H2>Demonstration</H2>
        <Title>E2E-Notification-system</Title>
      </CenteredText>
      <NotificationSection>
        <NotificationHeader>Notifications</NotificationHeader>
        <NotificationList>
          {notifications.map((notification) => (
            <NotificationItem key={notification._id}>
              {notification.message}
            </NotificationItem>
          ))}
        </NotificationList>
      </NotificationSection>
    </SectionContainer>
  </ApplicationContainer>
);
  • ApplicationContainer & SectionContainer: These styled components define the overall layout and appearance of the application. ApplicationContainer centers the content on the screen with a gradient background, while SectionContainer provides a white, rounded box with shadows for the main content.

  • Image: The Vite logo is displayed at the top of the application.

  • CenteredText, H2, Title: These components center and style the title and subtitle text.

  • NotificationSection: This section contains the list of notifications, with each notification being rendered as a NotificationItem.

  • notifications.map: The map function iterates over the notifications array and renders each notification message inside a styled list item (NotificationItem).

Styled Components

const ApplicationContainer = tw.div`bg-gradient-to-r from-indigo-500 to-purple-500 h-screen flex items-center justify-center`;

const SectionContainer = tw.div`bg-white shadow-lg rounded-lg p-8 max-w-3xl mx-auto`;

const CenteredText = tw.div`text-center`;

const H2 = tw.h2`font-bold text-lg text-indigo-600 uppercase tracking-wider mb-2`;

const Title = tw.p`text-gray-800 text-3xl sm:text-4xl font-extrabold leading-tight`;

const Image = tw.img`h-20 w-auto mx-auto my-6`;

const NotificationSection = tw.div`mt-10`;

const NotificationHeader = tw.h3`text-xl font-semibold text-gray-700 mb-4`;

const NotificationList = tw.ul`space-y-3`;

const NotificationItem = styled.li`
  ${tw`bg-indigo-100 text-indigo-700 p-4 rounded-lg shadow-md transition-all duration-300 ease-in-out`}
  &:hover {
    ${tw`bg-indigo-200`}
  }
`;
  • ApplicationContainer: This component styles the main container of the application with a gradient background and centers the content using flexbox.

  • SectionContainer: A white, shadowed box with rounded corners and padding to contain the main content.

  • CenteredText, H2, Title: Text components styled to be centered and display titles and subtitles with specific fonts, sizes, and colors.

  • NotificationSection & NotificationList: Layout containers for the notification section and list. NotificationList arranges notifications with spacing between them.

  • NotificationItem: Each notification is rendered as a styled list item. The item has a background color, padding, rounded corners, and a hover effect that changes the background color slightly.

Testing the Notification System

Sequence Diagram

Before we begin testing our notification system end-to-end, we should refer to the following sequence diagram for clarity on the sequence of events that will occur during the testing process.

  • User Action (Postman):

    • The user enters their credentials (username and password) in Postman.

    • The user makes a POST request to the /login endpoint on the Node.js backend using Postman.

  • Node.js Backend (Login Process):

    • The Node.js backend receives the credentials from Postman.

    • The backend verifies the credentials against its stored user data.

    • If the credentials are valid, the backend generates a JWT (JSON Web Token) for the user.

    • The backend returns the JWT token to Postman.

  • User Action (Storing JWT):

    • The user takes the JWT token received in Postman and manually places it in the browser’s local storage.
  • React Frontend (Using JWT for Requests):

    • The React frontend retrieves the JWT token from the browser’s local storage.

    • The React frontend uses the JWT token to make a request to the Node.js backend for notifications.

  • Node.js Backend (Fetching Notifications):

    • The Node.js backend receives the request for notifications, including the JWT token for authentication.

    • The backend verifies the JWT token.

    • The backend queries MongoDB to fetch notifications associated with the authenticated user.

  • MongoDB (Querying Notifications):

    • MongoDB processes the query and returns the relevant notifications to the Node.js backend.
  • Node.js Backend (Returning Notifications):

    • The Node.js backend sends the retrieved notifications back to the React frontend.
  • React Frontend (Establishing WebSocket Connection):

    • The React frontend establishes a WebSocket connection with the WebSocket server, using the JWT token for authentication.
  • Node.js Backend & WebSocket Server (Real-Time Notifications):

    • The Node.js backend sends a new notification to the WebSocket server whenever there is an update.

    • The WebSocket server emits the notification to the connected React frontend.

  • React Frontend (Displaying Notifications):

    • The React frontend receives the real-time notification from the WebSocket server.

    • The React frontend displays the new notification in the UI, where the user can see it.

The JWT token includes the userId of the logged-in user, and this userId is used to create a unique socket room for that user, This happens when the frontend establishes a handshake with the backend and we pass the JWT token to the backend. The notification payload also contains this userId, enabling the system to identify which user the notification is intended for and relay it to the appropriate socket room.

Testing the App

  1. First, ensure that both your frontend and backend are up and running.

    • To start the backend: npm run server

    • To start the frontend: npm run dev

  2. Open Postman and make two POST requests to http://localhost:4000/login with the following payloads:

    1.  {
           "username": "John",
           "password": "password"
       }
      
    2.  {
           "username": "Jane",
           "password": "password2"
       }
      

Please note that these credentials are hardcoded in the /login endpoint, so you should use these specific credentials unless you have a fully implemented authentication system in your application.

  1. Next, open two browser windows—one in normal mode and the other in incognito mode. In both windows, add a new localStorage value:

     "accessToken": your_access_token_from_server
    

    Note that using two different windows is important because localStorage is not shared between them, allowing you to simulate two separate users performing the test.

  2. Next, use Postman as either one of the users and create a new POST request to the http://localhost:4000/notifications endpoint.

    The payload of the request should be one of the following:

    1.  {
           "userId": "user2",
           "message": "My Test 1 for Jane's Notification",
           "Type": "General"
       }
      
    2.  {
           "userId": "user1",
           "message": "My Test 1 for John's Notification",
           "Type": "General"
       }
      

Please note that the userId in the payload determines which user will receive the notification. As you can see from the /login endpoint, these userIds are hardcoded for the respective users.

Also, make sure to add a new header when making this POST request:

authorization: bearer <accessToken>

Otherwise, the server will respond with a "Forbidden" error.

  1. You should now see that the notification has appeared on the frontend for only the user to whom the notification was intended.

    You can make additional POST requests to add new notifications and observe how the system behaves for the other user. Ensure that the authorization header includes the accessToken of the other logged-in user, and that the notification payload specifies the userId of the intended recipient.

Conclusion

Building a real-time notification system is a powerful way to keep users engaged and informed. By leveraging the MERN stack and Socket.io, we've created a robust and scalable solution that handles everything from user authentication to real-time updates. Not only does this setup enhance the user experience by delivering instant notifications, but it also provides a solid foundation for building more interactive features in your applications. Whether you're developing a social media platform, an e-commerce site, or any app that benefits from real-time data, the concepts covered in this guide will help you take your project to the next level. Happy coding!

Github

https://github.com/adeeshsharma/mern-notification-system

Did you find this article valuable?

Support Adeesh's Software Engineering Insights by becoming a sponsor. Any amount is appreciated!