When I started freelancing from Jaipur, one of the first “real” full-stack projects I built for learning and client demos was a real-time chat app. Not a toy alert box — a working MERN stack chat with login, rooms, live messages, and message history.
In this guide I walk through the same architecture I still use as a foundation: MongoDB + Express + React + Node.js, with Socket.io for real-time delivery. You will leave with a complete, runnable flow — not a half-finished intro.
Who this is for: developers who know basic JavaScript/React and want a production-minded chat skeleton they can extend for support widgets, community apps, or client MVPs.
What You Will Build
- User signup / login with JWT
- Persistent messages in MongoDB
- Live message broadcast with Socket.io
- A simple React chat UI (list + input)
- Basic security: auth on HTTP + socket handshake
Stack choices (and why):
- MongoDB — flexible documents for users and chat history
- Express — clean REST for auth and history
- React — component UI, easy socket lifecycle
- Socket.io — rooms, reconnect, browser-friendly WebSockets
Prerequisites
- Node.js 18+
- MongoDB (local or Atlas free cluster)
- Basic React hooks knowledge
- A code editor and terminal
node -v npm -v Project Structure
chat-app/ ├── server/ │ ├── package.json │ ├── index.js │ ├── models/ │ │ ├── User.js │ │ └── Message.js │ ├── middleware/ │ │ └── auth.js │ └── routes/ │ └── auth.js └── client/ ├── package.json └── src/ ├── App.jsx ├── api.js ├── socket.js └── components/ ├── Login.jsx └── Chat.jsx I keep backend and frontend separate so you can later host the API on Render/Railway and the React app on Vercel without rewriting everything.
Step 1 — Backend Setup
mkdir chat-app && cd chat-app mkdir server && cd server npm init -y npm install express mongoose cors dotenv bcryptjs jsonwebtoken socket.io npm install -D nodemon server/.env:
PORT=5000 MONGO_URI=mongodb://127.0.0.1:27017/mern-chat JWT_SECRET=replace_with_a_long_random_string CLIENT_URL=http://localhost:5173 Tip: Never commit real secrets. For production, use environment variables on your host.
User and Message models
server/models/User.js:
const mongoose = require('mongoose'); const userSchema = new mongoose.Schema( { username: { type: String, required: true, unique: true, trim: true, minlength: 3 }, passwordHash: { type: String, required: true }, }, { timestamps: true } ); module.exports = mongoose.model('User', userSchema); server/models/Message.js:
const mongoose = require('mongoose'); const messageSchema = new mongoose.Schema( { room: { type: String, required: true, default: 'general', index: true }, sender: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, text: { type: String, required: true, trim: true, maxlength: 2000 }, }, { timestamps: true } ); module.exports = mongoose.model('Message', messageSchema); I index room because history queries always filter by room. That small detail matters once you have thousands of messages.
Auth middleware
server/middleware/auth.js:
const jwt = require('jsonwebtoken'); function auth(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) return res.status(401).json({ error: 'Missing token' }); try { const payload = jwt.verify(token, process.env.JWT_SECRET); req.user = { id: payload.id, username: payload.username }; next(); } catch { return res.status(401).json({ error: 'Invalid token' }); } } module.exports = auth; Auth routes
server/routes/auth.js:
const express = require('express'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const User = require('../models/User'); const router = express.Router(); router.post('/signup', async (req, res) => { try { const username = String(req.body.username || '').trim().toLowerCase(); const password = String(req.body.password || ''); if (username.length < 3 || password.length < 6) { return res.status(400).json({ error: 'Username min 3 chars, password min 6' }); } const exists = await User.findOne({ username }); if (exists) return res.status(409).json({ error: 'Username already taken' }); const passwordHash = await bcrypt.hash(password, 10); const user = await User.create({ username, passwordHash }); const token = jwt.sign( { id: user._id.toString(), username: user.username }, process.env.JWT_SECRET, { expiresIn: '7d' } ); res.status(201).json({ token, user: { id: user._id, username: user.username } }); } catch (err) { console.error(err); res.status(500).json({ error: 'Signup failed' }); } }); router.post('/login', async (req, res) => { try { const username = String(req.body.username || '').trim().toLowerCase(); const password = String(req.body.password || ''); const user = await User.findOne({ username }); if (!user) return res.status(401).json({ error: 'Invalid credentials' }); const ok = await bcrypt.compare(password, user.passwordHash); if (!ok) return res.status(401).json({ error: 'Invalid credentials' }); const token = jwt.sign( { id: user._id.toString(), username: user.username }, process.env.JWT_SECRET, { expiresIn: '7d' } ); res.json({ token, user: { id: user._id, username: user.username } }); } catch (err) { console.error(err); res.status(500).json({ error: 'Login failed' }); } }); module.exports = router; Step 2 — Express + Socket.io Server
server/index.js is the heart of the app: one HTTP server shared by Express and Socket.io.
require('dotenv').config(); const http = require('http'); const express = require('express'); const cors = require('cors'); const mongoose = require('mongoose'); const jwt = require('jsonwebtoken'); const { Server } = require('socket.io'); const authRoutes = require('./routes/auth'); const auth = require('./middleware/auth'); const Message = require('./models/Message'); const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { origin: process.env.CLIENT_URL, methods: ['GET', 'POST'], }, }); app.use(cors({ origin: process.env.CLIENT_URL })); app.use(express.json({ limit: '16kb' })); app.get('/health', (_req, res) => res.json({ ok: true })); app.use('/api/auth', authRoutes); // Load last 50 messages for a room (history after refresh) app.get('/api/messages/:room', auth, async (req, res) => { try { const room = req.params.room || 'general'; const messages = await Message.find({ room }) .sort({ createdAt: -1 }) .limit(50) .populate('sender', 'username') .lean(); res.json( messages .reverse() .map((m) => ({ id: m._id, room: m.room, text: m.text, username: m.sender?.username || 'unknown', createdAt: m.createdAt, })) ); } catch (err) { console.error(err); res.status(500).json({ error: 'Could not load messages' }); } }); // Socket auth: same JWT as REST io.use((socket, next) => { try { const token = socket.handshake.auth?.token; if (!token) return next(new Error('Unauthorized')); const payload = jwt.verify(token, process.env.JWT_SECRET); socket.user = { id: payload.id, username: payload.username }; next(); } catch { next(new Error('Unauthorized')); } }); io.on('connection', (socket) => { console.log('connected:', socket.user.username); socket.on('join_room', (room = 'general') => { const safeRoom = String(room).slice(0, 40); socket.join(safeRoom); socket.data.room = safeRoom; socket.to(safeRoom).emit('system', { text: `${socket.user.username} joined`, }); }); socket.on('send_message', async ({ text, room }) => { try { const clean = String(text || '').trim().slice(0, 2000); const targetRoom = String(room || socket.data.room || 'general').slice(0, 40); if (!clean) return; const doc = await Message.create({ room: targetRoom, sender: socket.user.id, text: clean, }); const payload = { id: doc._id, room: targetRoom, text: clean, username: socket.user.username, createdAt: doc.createdAt, }; // Emit to everyone in the room (including sender for consistency) io.to(targetRoom).emit('new_message', payload); } catch (err) { console.error(err); socket.emit('error_message', { error: 'Message not sent' }); } }); socket.on('disconnect', () => { console.log('disconnected:', socket.user.username); }); }); async function start() { await mongoose.connect(process.env.MONGO_URI); console.log('MongoDB connected'); server.listen(process.env.PORT || 5000, () => { console.log(`API + Socket on :${process.env.PORT || 5000}`); }); } start().catch((err) => { console.error(err); process.exit(1); }); Why auth on the socket matters: If you only protect REST routes, anyone can open a socket and spam your room. Handshake JWT verification is non-negotiable for a serious demo.
Add to server/package.json:
"scripts": { "dev": "nodemon index.js", "start": "node index.js" } npm run dev Step 3 — React Client
cd .. npm create vite@latest client -- --template react cd client npm install npm install socket.io-client npm run dev Set the API base in client/.env:
VITE_API_URL=http://localhost:5000 client/src/api.js:
const API = import.meta.env.VITE_API_URL; export async function signup(username, password) { const res Source:https://dev.to/vishvajeetshukla/building-a-real-time-chat-app-with-mern-and-socketio-complete-guide-39f2