Admissions: +91 - 8288898268 Counselling: +91 - 9041417268 +91 - 9041417268 Apply Now

How to Build a MERN Stack Real Time Chat App from Scratch

Create a full-stack messaging project with user login, instant message delivery, chat history, MongoDB storage, and Socket.IO connections for live communication.

MERN Development • Oct 28, 2026

Short answer: To build a MERN stack real time chat app, create an Express server with Socket.IO, store users and messages in MongoDB with Mongoose, protect the connection with JWT login, and build a React client that sends and receives messages through a socket. The whole app takes about eight steps, and a working version needs roughly 300 lines of code.

This guide walks through each step of building a MERN stack chat application in a clear, step-by-step order, with code you can run, a testing checklist, and a section on what changes as the app grows. It is written for developers who know basic JavaScript and want to build a real-time messaging app using React and Node.js that they can extend.

Starting your career in full-stack development? Check Digittrix Academy's guide to full-stack developer salaries for freshers to understand the skills, job roles, and salary expectations that can help you plan your career!

What Is a MERN Stack Real Time Chat App?

A MERN stack real time chat app is a messaging application built with four tools: MongoDB (database), Express (server framework), React (user interface), and Node.js (runtime). A fifth piece, Socket.IO, handles live message delivery.

“Real-time” means a message appears on the other person’s screen without a page refresh. Normal HTTP requests cannot do this because the browser must request an update from the server for each one. A WebSocket connection stays open, so the server can push a message the moment it arrives. Socket.IO builds on WebSockets and falls back to other methods when a network blocks them.

Typical uses include customer support chat, team messaging, buyer-seller chat on marketplaces, and in-app help desks. Startups often build this feature first because it is easy to demonstrate to investors and users.

What You Need Before You Start a MERN Stack Project for Beginners

  • Node.js (a current LTS version) and npm installed
  • A MongoDB database, either local or a free MongoDB Atlas cluster
  • Basic knowledge of React hooks and async JavaScript
  • A code editor and a tool such as Postman for testing routes

How the MERN Stack Real Time Chat App Works: Architecture at a Glance

Part

Tool

Job

Interface

React

Shows chats, sends input, listens for new messages

API server

Express + Node.js

Handles login, user data and message history

Live channel

Socket.IO

Sends messages between connected users instantly

Database

MongoDB + Mongoose

Stores users, rooms and messages

Login

JSON Web Tokens (JWT)

Proves who is sending each message

In a Socket.IO chat application, the flow is simple. A user logs in and receives a token. The React app opens a socket connection and sends the token. The server verifies it, then lets the user join a room. When the user sends a message, the server saves it to MongoDB and broadcasts it to everyone in that room.

Step 1: Set Up the Project Folders

Create two folders, one for each side of the app.

bash

mkdir chat-app && cd chat-app

mkdir server client

cd server && npm init -y

npm install express mongoose socket.io jsonwebtoken bcryptjs cors dotenv

npm install -D nodemon

cd ../client && npm create vite@latest . -- --template react

npm install socket.io-client axios

Keeping the server and client separate makes deployment easier, since each can run on its own host.

Step 2: Create the Express Server and Connect MongoDB

Add a .env file in server with MONGO_URI, JWT_SECRET and PORT. Then create server/index.js:

js

require('dotenv').config();

const express = require('express');

const http = require('http');

const mongoose = require('mongoose');

const cors = require('cors');

const { Server } = require('socket.io');

 

const app = express();

app.use(cors({ origin: 'http://localhost:5173' }));

app.use(express.json());

 

const server = http.createServer(app);

const io = new Server(server, {

  cors: { origin: 'http://localhost:5173' }

});

 

mongoose.connect(process.env.MONGO_URI)

  .then(() => console.log('MongoDB connected'))

  .catch(err => console.error(err));

 

server.listen(process.env.PORT || 5000);

Notice that Socket.IO attaches to the HTTP server, not the Express app. This lets them share one port.

Step 3: Define the Mongoose Models for the MongoDB Chat App Project

You need two collections: users and messages.

js

// models/User.js

const mongoose = require('mongoose');

module.exports = mongoose.model('User', new mongoose.Schema({

  username: { type: String, required: true, unique: true },

  password: { type: String, required: true }

}));

 

// models/Message.js

module.exports = mongoose.model('Message', new mongoose.Schema({

  room: { type: String, required: true, index: true },

  sender: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },

  text: { type: String, required: true, maxlength: 2000 }

}, { timestamps: true }));

The index on room keeps history queries fast as the collection grows. The maxlength limit stops oversized messages from reaching the database.

Step 4: Add Registration and Login with JWT

Passwords must never be stored as plain text. Use bcrypt to hash them, and return a signed token upon login.

js

// routes/auth.js

const router = require('express').Router();

const bcrypt = require('bcryptjs');

const jwt = require('jsonwebtoken');

const User = require('../models/User');

 

router.post('/register', async (req, res) => {

  const hash = await bcrypt.hash(req.body.password, 10);

  const user = await User.create({ username: req.body.username, password: hash });

  res.status(201).json({ id: user._id });

});

 

router.post('/login', async (req, res) => {

  const user = await User.findOne({ username: req.body.username });

  if (!user || !(await bcrypt.compare(req.body.password, user.password)))

    return res.status(401).json({ message: 'Wrong username or password' });

  const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '1d' });

  res.json({ token, username: user.username });

});

 

module.exports = router;

Mount it in index.js with app.use('/api/auth', require('./routes/auth')).

Step 5: Build the Socket.IO Chat Application Logic on the Server

This is the core of the app. First, a middleware checks the token before any socket connection is established. Then the server handles joining rooms and sending messages.

js

const jwt = require('jsonwebtoken');

const Message = require('./models/Message');

 

io.use((socket, next) => {

  try {

    const payload = jwt.verify(socket.handshake.auth.token, process.env.JWT_SECRET);

    socket.userId = payload.id;

    next();

  } catch {

    next(new Error('Not authorized'));

  }

});

 

io.on('connection', (socket) => {

  socket.on('join_room', (room) => socket.join(room));

 

  socket.on('send_message', async ({ room, text }) => {

    const msg = await Message.create({ room, text, sender: socket.userId });

    io.to(room).emit('new_message', {

      _id: msg._id, room, text, sender: socket.userId, createdAt: msg.createdAt

    });

  });

 

  socket.on('typing', (room) => socket.to(room).emit('user_typing', socket.userId));

});

Two points matter here. The sender ID comes from the verified token, not from the client message, so users cannot impersonate someone else. And the server saves the message before sending it, so the history remains complete even if a client disconnects.

Step 6: Add a Route for Message History

When a user opens a room, the app should load older messages.

js

router.get('/messages/:room', async (req, res) => {

  const messages = await Message.find({ room: req.params.room })

    .sort({ createdAt: -1 }).limit(50).populate('sender', 'username');

  res.json(messages.reverse());

});

Protect this route with middleware that verifies the same JWT. Load 50 messages at a time to keep the first screen quick. Add a “load more” button that passes the oldest message date as a cursor.

Step 7: Build the React Chat Client

Create a single socket connection and keep it outside the component so it does not reconnect on every render.

jsx

// src/Chat.jsx

import { useEffect, useState, useRef } from 'react';

import { io } from 'socket.io-client';

import axios from 'axios';

 

export default function Chat({ token, room }) {

  const [messages, setMessages] = useState([]);

  const [text, setText] = useState('');

  const socketRef = useRef(null);

 

  useEffect(() => {

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

    socketRef.current = socket;

    socket.emit('join_room', room);

 

    axios.get(`http://localhost:5000/api/messages/${room}`, {

      headers: { Authorization: `Bearer ${token}` }

    }).then(res => setMessages(res.data));

 

    socket.on('new_message', (msg) => setMessages(prev => [...prev, msg]));

    return () => socket.disconnect();

  }, [token, room]);

 

  const send = (e) => {

    e.preventDefault();

    if (!text.trim()) return;

    socketRef.current.emit('send_message', { room, text });

    setText('');

  };

 

  return (

    <div>

      {messages.map(m => <p key={m._id}>{m.text}</p>)}

      <form onSubmit={send}>

        <input value={text} onChange={e => setText(e.target.value)} />

        <button>Send</button>

      </form>

    </div>

  );

}

The cleanup function in useEffect closes the socket when the component unmounts. Without it, you will see duplicate messages when navigating between pages.

Step 8: Add Typing Indicators and Online Status

These small features make a chat app feel finished. For typing, emit the typing event from the input’s onChange, and show “Someone is typing” for two seconds after each user_typing event. For online status, keep a Map of connected user IDs on the server, update it in the connection and disconnect handlers, and send the list to clients with a presence_update event.

Throttle the typing event so it fires at most once per second. Sending one on every key press wastes bandwidth.

How to Test Your MERN Stack Real Time Chat App

  • Open two browser windows, log in as different users, and join the same room.
  • Send a message from window one and check that it appears in window two without a refresh.
  • Refresh window two and confirm the history loads from MongoDB.
  • Disconnect your network for ten seconds, reconnect, and check that Socket.IO restores the connection.
  • Try connecting with a wrong token and confirm the server rejects it.
  • Send a 3,000-character message and confirm the length limit blocks it.

Common Errors and Fixes

Problem

Likely cause

Fix

CORS error in the browser

Origin missing from server settings

Add the client URL to both Express cors and the Socket.IO cors option

Messages appear twice

Listener added on every render

Register listeners inside useEffect and remove them on cleanup

Socket connects, then drops

Token expired or invalid

Check JWT_SECRET matches and refresh the token

History is slow

No index on room

Add an index and use limit

Works locally, fails after deploy

Proxy blocks WebSockets

Enable WebSocket support on the host or reverse proxy

Security Checklist for Your MERN Stack Chat Application Before You Go Live

  • Hash passwords with bcrypt and never log them.
  • Store secrets in environment variables, not in code.
  • Verify the token on every socket connection and every protected route.
  • Check that a user belongs to a room before letting them join it.
  • Limit message length and request rates to reduce spam.
  • Escape or sanitise message text before showing it, since React escapes by default but any HTML rendering you add later may not.
  • Serve the app over HTTPS and use secure WebSocket connections (wss://).
  • Review your approach against the OWASP guidance for web application security.

What Changes When the App Grows

One Node.js server can run a small MERN stack real time chat app, but a second server breaks live delivery. A message sent through server A will not reach users connected to server B. The usual fix is the Socket.IO Redis adapter, which forwards events between servers. Add a load balancer with sticky sessions so each client remains on a single server throughout its connection.

Other upgrades to plan for:

  • Read receipts and unread counts stored per user per room
  • File and image sharing through cloud storage, saving only the link in MongoDB
  • Push notifications for offline users
  • Message search using MongoDB text indexes or a dedicated search tool
  • Moderation tools such as blocking, reporting and admin deletion

For a product that will face customers or investors, these items affect cost and delivery time, so list them in your scope early.

Looking to turn your coding skills into a career? Check Digittrix Academy's guide to full-stack developer training and placement to understand how practical learning and placement support can help you prepare for job opportunities!

Want to Build a MERN Stack Real Time Chat App With Guidance?

A chat app handles live user data and private conversations, so it is best to build one with feedback from someone who has built one. Digittrix Academy teaches the MERN stack through hands-on training, where you build real features such as JWT login, Socket.IO rooms, message history in MongoDB, typing indicators, and deployment. If you want to build these skills with a mentor, send your questions to tanvi@digittrix.com, and the Digittrix Academy team will share course details and batch information.

Frequently Asked Questions

Use Express with Socket.IO on the server, MongoDB for storage, and React for the interface. Authenticate with JWT, save each message before broadcasting it, and load history via a REST route.

No. WebSocket is a browser and network protocol. Socket.IO is a library that uses WebSockets when possible and adds features such as rooms, automatic reconnection, and fallback transports..

Yes. A MongoDB chat app project can store message documents effectively, and an index on the room field and timestamp keeps reads fast. Very large platforms may split old messages into archive collections.

A basic version with login, rooms, and history can take a developer a few days. A product with files, notifications, moderation, and testing takes several weeks, depending on scope.

How do I stop users from reading rooms they do not belong to? Store room membership in the database, and check it in the join_room handler and the history route before allowing access.