MERN Stack

Building a Chat Application with User & Admin Panel using MERN Stack

By the Domain India teamPublished 9 min read
Knowledge base article
Contents (8 sections)

A chat app is a good MERN project because it touches accounts, a database, real-time messaging and permissions at once. This guide builds one with MongoDB, Express 5, React and Node.js, with Socket.IO for live messages and an admin panel that can ban users and remove messages, secure from the start.

Key takeaways

Use Express 5 with cookie sessions for login, hash passwords with Argon2, and share the same session with Socket.IO so every socket knows exactly which user it belongs to. Never trust a username or role sent by the browser. Check the admin role on the server, both for REST routes and for socket events. Build the React client with Vite and deploy behind a proxy that passes WebSocket upgrades.

1. What you will build

User panel
Register, sign in, join a room and chat in real time
Admin panel
List users, ban them and delete messages
Server
Express 5 API, MongoDB via Mongoose, Socket.IO for live events

Use a current Node.js LTS release (24 at the time of writing) and MongoDB, either installed locally or a hosted MongoDB service.

bash
mkdir chat-app && cd chat-app && mkdir server
cd server && npm init -y && npm pkg set type=module
npm install express express-session connect-mongo mongoose socket.io argon2 express-rate-limit

2. The data models

The role lives only in the database. Registration never sets it.

javascript
// server/models.js
import mongoose from 'mongoose';

export const User = mongoose.model('User', new mongoose.Schema({
  username: { type: String, required: true, unique: true, trim: true, match: /^[a-z0-9_]{3,20}$/ },
  passwordHash: { type: String, required: true },
  role: { type: String, enum: ['user', 'admin'], default: 'user' },
  banned: { type: Boolean, default: false },
}, { timestamps: true }));

export const Message = mongoose.model('Message', new mongoose.Schema({
  room: { type: String, required: true, index: true },
  author: { type: mongoose.Types.ObjectId, ref: 'User', required: true },
  username: { type: String, required: true },
  text: { type: String, required: true, maxlength: 2000 },
}, { timestamps: true }));

3. Server, sessions and login

One session middleware is shared by Express and Socket.IO. Passwords are hashed with Argon2id, and the login route is rate-limited to slow down password guessing.

javascript
// server/index.js  (run with: node --env-file=.env index.js)
import express from 'express';
import { createServer } from 'node:http';
import session from 'express-session';
import MongoStore from 'connect-mongo';
import mongoose from 'mongoose';
import argon2 from 'argon2';
import { rateLimit } from 'express-rate-limit';
import { Server } from 'socket.io';
import { User, Message } from './models.js';

await mongoose.connect(process.env.MONGODB_URI);
const app = express();
const httpServer = createServer(app);
app.set('trust proxy', 1);
app.use(express.json({ limit: '10kb' }));

const sessionMiddleware = session({
  name: 'sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  store: MongoStore.create({ mongoUrl: process.env.MONGODB_URI }),
  cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' },
});
app.use(sessionMiddleware);

const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10 });

app.post('/api/register', loginLimiter, async (req, res) => {
  const { username, password } = req.body;
  if (typeof password !== 'string' || password.length < 10) {
    return res.status(400).json({ error: 'Password must be at least 10 characters' });
  }
  try {
    const user = await User.create({ username, passwordHash: await argon2.hash(password) });
    req.session.regenerate(() => {
      req.session.userId = user.id;
      res.status(201).json({ username: user.username, role: user.role });
    });
  } catch {
    res.status(400).json({ error: 'Username unavailable or invalid' });
  }
});

app.post('/api/login', loginLimiter, async (req, res) => {
  const user = await User.findOne({ username: String(req.body.username) });
  const ok = user && !user.banned && (await argon2.verify(user.passwordHash, String(req.body.password)));
  if (!ok) return res.status(401).json({ error: 'Invalid username or password' });
  req.session.regenerate(() => {
    req.session.userId = user.id;
    res.json({ username: user.username, role: user.role });
  });
});

regenerate() issues a new session ID after login, preventing session fixation, and one error message for a wrong username or password hides which usernames exist.

4. Protect routes, then build the admin API

The code in this section and the next continues index.js. Load the user from the database on every request instead of trusting what the session stored at login. A ban or a role change then takes effect immediately.

javascript
async function requireUser(req, res, next) {
  const user = req.session.userId && (await User.findById(req.session.userId));
  if (!user || user.banned) return res.status(401).json({ error: 'Sign in required' });
  req.user = user;
  next();
}
const requireAdmin = (req, res, next) =>
  req.user.role === 'admin' ? next() : res.status(403).json({ error: 'Admins only' });

app.get('/api/me', requireUser, (req, res) => res.json({ username: req.user.username, role: req.user.role }));

app.get('/api/admin/users', requireUser, requireAdmin, async (req, res) => {
  res.json(await User.find().select('username role banned createdAt').sort({ createdAt: -1 }).limit(200));
});

app.post('/api/admin/users/:id/ban', requireUser, requireAdmin, async (req, res) => {
  await User.findByIdAndUpdate(req.params.id, { banned: true });
  io.in(`user:${req.params.id}`).disconnectSockets(true); // kick them out of chat now
  res.status(204).end();
});

app.delete('/api/admin/messages/:id', requireUser, requireAdmin, async (req, res) => {
  const msg = await Message.findByIdAndDelete(req.params.id);
  if (msg) io.to(msg.room).emit('message:deleted', msg.id);
  res.status(204).end();
});

Create your first admin by hand, for example in mongosh: db.users.updateOne({ username: "yourname" }, { $set: { role: "admin" } }).

5. Real-time messaging with Socket.IO

Socket.IO 4 can reuse the Express session through io.engine.use(). Every connection is then tied to a signed-in user, and the server, not the browser, decides who the author of a message is.

javascript
const io = new Server(httpServer); // same origin, so no CORS setup is needed
io.engine.use(sessionMiddleware);

io.use(async (socket, next) => {
  const id = socket.request.session?.userId;
  const user = id && (await User.findById(id));
  if (!user || user.banned) return next(new Error('unauthorized'));
  socket.data.user = { id: user.id, username: user.username };
  next();
});

const ROOMS = new Set(['general', 'help']);

io.on('connection', (socket) => {
  socket.join(`user:${socket.data.user.id}`);

  socket.on('room:join', async (room, ack) => {
    if (!ROOMS.has(room)) return ack?.({ error: 'Unknown room' });
    socket.join(room);
    const history = await Message.find({ room }).sort({ createdAt: -1 }).limit(50);
    ack?.({ history: history.reverse() });
  });

  socket.on('message', async (payload) => {
    const { room, text } = payload ?? {};
    if (!socket.rooms.has(room) || typeof text !== 'string') return;
    const clean = text.trim().slice(0, 2000);
    if (!clean) return;
    const msg = await Message.create({
      room, text: clean, author: socket.data.user.id, username: socket.data.user.username,
    });
    io.to(room).emit('message', msg);
  });
});

httpServer.listen(process.env.PORT || 3000);

Add a simple per-socket limit, such as a few messages per second, before you open the app to the public.

6. The React client

Create it with Vite and add the Socket.IO client and React Router:

bash
npm create vite@latest client -- --template react
cd client && npm install socket.io-client react-router

In vite.config.js, proxy /api and /socket.io (with ws: true) to http://localhost:3000 during development, so the browser sees one origin and sends the session cookie.

jsx
// client/src/Chat.jsx
import { useEffect, useState } from 'react';
import { io } from 'socket.io-client';

export default function Chat({ room = 'general' }) {
  const [socket, setSocket] = useState(null);
  const [messages, setMessages] = useState([]);
  const [text, setText] = useState('');

  useEffect(() => {
    const s = io(); // connects to the same origin and sends the cookie
    s.emit('room:join', room, (res) => res.history && setMessages(res.history));
    s.on('message', (m) => setMessages((prev) => [...prev, m]));
    s.on('message:deleted', (id) => setMessages((prev) => prev.filter((m) => m._id !== id)));
    setSocket(s);
    return () => s.disconnect();
  }, [room]);

  function send(e) {
    e.preventDefault();
    socket?.emit('message', { room, text });
    setText('');
  }

  return (
    <section>
      <ul>{messages.map((m) => <li key={m._id}><strong>{m.username}</strong>: {m.text}</li>)}</ul>
      <form onSubmit={send}>
        <input value={text} onChange={(e) => setText(e.target.value)} maxLength={2000} />
        <button>Send</button>
      </form>
    </section>
  );
}

React escapes text, so a message containing script tags shows as plain text; never render messages with dangerouslySetInnerHTML. The admin page calls /api/admin/users and the ban and delete routes. Hiding its link from normal users is cosmetic; the server enforces the role.

7. Deploy behind a WebSocket-aware proxy

Build the client with npm run build and let Express serve client/dist, or let nginx serve it. Whatever proxy sits in front of Node must pass WebSocket upgrades for /socket.io/:

nginx
location /socket.io/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Run one Node.js process. Several processes need sticky sessions and a Socket.IO adapter (such as the Redis adapter).

8. Running this on Domain India

  • VPS: the natural home for this app. A Domain India VPS gives you full root access and is self-managed: you install Node.js, MongoDB and nginx, get a free Let's Encrypt certificate with Certbot (its package sets up renewal for you), and keep the app running with systemd or PM2. There is no cPanel. See Running MERN/MEAN on a clean VPS and the VPS security checklist.
  • App Platform: Node.js is detected and built automatically, and you deploy with Deploy Now from GitHub or with a deploy token. The included database is PostgreSQL, not MongoDB, so you would switch the models to PostgreSQL or use an external MongoDB service. Ask support to confirm WebSocket support for your app before you rely on it; Socket.IO falls back to HTTP long-polling when WebSockets are unavailable. See Getting started with the App Platform.
  • Shared hosting is not a good fit for a real-time chat server: apps there run to answer web requests within shared resource limits.
VPS Starter
₹552.65/mo + GST
  • 1 vCPU
  • 2 GB DDR4 RAM
  • 64 GB NVMe SSD Storage
  • 2 TB Monthly Bandwidth
See plan details
App Developer
₹250/mo + GST
  • 512 MB RAM per app
  • 1.5 GB RAM total
  • 2 vCPU
  • 10 GB NVMe SSD
See plan details
Should I use JWTs or sessions for a MERN chat app?

For a web app served from one site, an httpOnly session cookie is simpler and safer, and Socket.IO can reuse the same session. JWTs suit mobile apps or APIs used by other services.

How do I authenticate Socket.IO connections?

Share the Express session middleware with Socket.IO using io.engine.use(), then reject connections without a valid user in an io.use() middleware. Take the author of every message from the session, never from the client.

How do I stop normal users reaching the admin panel?

Check the role on the server for every admin route and admin socket event, and load it from the database on each request. Hiding the link in React does not protect anything.

Which password hashing should I use?

Argon2id through the argon2 package, or bcrypt if Argon2 is unavailable. Never store plain or reversibly encrypted passwords.

Why does my chat connect but never receive messages in production?

Usually the proxy is not passing WebSocket upgrades. Add the Upgrade and Connection headers for the /socket.io/ path, and check that the session cookie reaches the server over HTTPS.

Can I host this chat app on Domain India?

Yes. A VPS gives you full control for Node.js, MongoDB and nginx. The App Platform runs Node.js apps too, with PostgreSQL included; ask support to confirm WebSocket support first.

Ready to launch your chat app? Compare VPS plans, look at the App Platform, or ask us in a support ticket which fits your project.

Run your real-time app on a VPS

Full root access for Node.js, MongoDB and nginx, managed your way.

See VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app
MERN Chat App with Admin Panel and Socket.IO