
Building a Real-Time Chat App with MERN and Socket.io (Complete Guide)
Complete MERN + Socket.io chat tutorial: JWT auth, MongoDB message history, live rooms, React UI, security checklist, and deploy tips — written for developers shipping real projects.
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 = await fetch(`${API}/api/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Signup failed');
return data;
}
export async function login(username, password) {
const res = await fetch(`${API}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
return data;
}
export async function fetchMessages(token, room = 'general') {
const res = await fetch(`${API}/api/messages/${room}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'History failed');
return data;
}
client/src/socket.js:
import { io } from 'socket.io-client';
const API = import.meta.env.VITE_API_URL;
export function createSocket(token) {
return io(API, {
auth: { token },
autoConnect: true,
});
}
client/src/components/Login.jsx:
import { useState } from 'react';
import { login, signup } from '../api';
export default function Login({ onAuth }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [mode, setMode] = useState('login');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError('');
setLoading(true);
try {
const fn = mode === 'login' ? login : signup;
const data = await fn(username, password);
localStorage.setItem('chat_token', data.token);
localStorage.setItem('chat_user', JSON.stringify(data.user));
onAuth(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 360, margin: '4rem auto' }}>
<h1>{mode === 'login' ? 'Login' : 'Sign up'}</h1>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
<input
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button disabled={loading} type="submit">
{loading ? 'Please wait…' : mode === 'login' ? 'Login' : 'Create account'}
</button>
<button type="button" onClick={() => setMode(mode === 'login' ? 'signup' : 'login')}>
Switch to {mode === 'login' ? 'Sign up' : 'Login'}
</button>
</form>
);
}
client/src/components/Chat.jsx:
import { useEffect, useRef, useState } from 'react';
import { fetchMessages } from '../api';
import { createSocket } from '../socket';
const ROOM = 'general';
export default function Chat({ auth, onLogout }) {
const [messages, setMessages] = useState([]);
const [text, setText] = useState('');
const [status, setStatus] = useState('connecting');
const bottomRef = useRef(null);
const socketRef = useRef(null);
useEffect(() => {
let active = true;
const socket = createSocket(auth.token);
socketRef.current = socket;
(async () => {
try {
const history = await fetchMessages(auth.token, ROOM);
if (active) setMessages(history);
} catch (err) {
console.error(err);
}
})();
socket.on('connect', () => {
setStatus('online');
socket.emit('join_room', ROOM);
});
socket.on('disconnect', () => setStatus('offline'));
socket.on('connect_error', () => setStatus('auth error'));
socket.on('new_message', (msg) => {
setMessages((prev) => [...prev, msg]);
});
socket.on('system', (msg) => {
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), text: msg.text, username: 'system', createdAt: new Date().toISOString() },
]);
});
return () => {
active = false;
socket.disconnect();
};
}, [auth.token]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
function send(e) {
e.preventDefault();
const clean = text.trim();
if (!clean || !socketRef.current) return;
socketRef.current.emit('send_message', { text: clean, room: ROOM });
setText('');
}
return (
<div style={{ maxWidth: 640, margin: '2rem auto' }}>
<header style={{ display: 'flex', justifyContent: 'space-between' }}>
<div>
<strong>#{ROOM}</strong> · {auth.user.username} · {status}
</div>
<button onClick={onLogout}>Logout</button>
</header>
<div style={{ height: 420, overflowY: 'auto', border: '1px solid #ddd', padding: 12, marginTop: 12 }}>
{messages.map((m) => (
<div key={m.id} style={{ marginBottom: 8 }}>
<strong>{m.username}</strong>:{' '}
<span>{m.text}</span>
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={send} style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input
style={{ flex: 1 }}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type a message…"
maxLength={2000}
/>
<button type="submit">Send</button>
</form>
</div>
);
}
client/src/App.jsx:
import { useMemo, useState } from 'react';
import Login from './components/Login';
import Chat from './components/Chat';
export default function App() {
const initial = useMemo(() => {
const token = localStorage.getItem('chat_token');
const userRaw = localStorage.getItem('chat_user');
if (!token || !userRaw) return null;
try {
return { token, user: JSON.parse(userRaw) };
} catch {
return null;
}
}, []);
const [auth, setAuth] = useState(initial);
function handleLogout() {
localStorage.removeItem('chat_token');
localStorage.removeItem('chat_user');
setAuth(null);
}
if (!auth) return <Login onAuth={setAuth} />;
return <Chat auth={auth} onLogout={handleLogout} />;
}
Open two browsers (or one normal + one incognito), create two users, join general, and send messages. You should see live delivery without refresh — that is Socket.io doing its job.
How the Real-Time Flow Works
- User logs in → server returns JWT.
- React stores JWT and opens a Socket.io connection with
auth: { token }. - Server verifies JWT before accepting the socket.
- Client joins room
general. - Client loads last 50 messages via REST (so refresh is not empty).
- On send, server saves MongoDB document, then
io.to(room).emit('new_message'). - Every client in the room appends the message to UI state.
That split — REST for history, sockets for live events — is the pattern I recommend for client projects. Pure sockets-only apps get messy for pagination and SEO tooling; pure REST-only apps feel laggy.
Security Checklist (Do Not Skip)
- Hash passwords with bcrypt (never store plain text).
- Verify JWT on sockets, not only on HTTP routes.
- Limit message length (we used 2000 chars) to stop abuse.
- Sanitize room names so users cannot invent huge random rooms forever without control.
- Rate-limit login and send events in production (express-rate-limit + socket throttling).
- CORS locked to your real frontend origin.
- For public apps, add report/block features and moderation later.
Common Mistakes I See (and How to Fix Them)
| Problem | Likely cause | Fix |
|---|---|---|
| Messages vanish on refresh | Only using sockets, no DB history | Save to MongoDB + GET history endpoint |
| Socket connects then drops | JWT missing/expired in handshake | Pass token in auth, handle reconnect after re-login |
| CORS errors in browser | Client URL not allowed | Match CLIENT_URL and Vite origin exactly |
| Duplicate messages | Emitting to sender twice incorrectly | Use one room emit path; avoid optimistic double-append bugs |
| Works locally, fails online | Mixed HTTP/HTTPS or wrong API URL | HTTPS everywhere; env vars per environment |
Optional Upgrades (When Clients Ask)
- Private DMs: room id = sorted pair of user ids
- Typing indicators:
socket.emit('typing')with debounce - Read receipts: store last-read timestamps
- File uploads: Cloudinary / S3 + message type
image - Presence: Redis adapter when you scale past one server process
- Next.js frontend: same socket client works; keep API on a long-lived Node server (Vercel serverless is a poor fit for Socket.io alone)
For multi-instance deploys, add the Socket.io Redis adapter so rooms sync across processes. One Node process is fine for demos and early MVPs.
Deploy Outline (Practical)
- MongoDB Atlas free cluster → copy URI into server env.
- Deploy
serverto Railway / Render / a VPS (needs sticky sessions or Redis adapter for multi-instance). - Deploy
clientto Vercel/Netlify withVITE_API_URLpointing to your API. - Update
CLIENT_URLon the server to the live frontend origin.
If you want a single-domain setup later, put Nginx or Cloudflare in front and proxy /api + /socket.io to Node.
What This Project Teaches Employers / Clients
A complete chat app signals more than “I know React.” It shows you can:
- Design data models
- Handle auth correctly
- Mix REST and real-time protocols
- Think about security and edge cases
- Ship something demoable in a portfolio
When I talk to local businesses in Jaipur about custom dashboards or support chat, this is the same backbone — just restyled and connected to their leads or orders.
Frequently Asked Questions
Is Socket.io better than raw WebSockets?
Raw WebSockets are lighter. Socket.io adds reconnect, rooms, fallbacks, and a nicer event API. For freelance MVPs and teaching projects, Socket.io is usually the faster path.
Can I use this with Next.js instead of Vite?
Yes for the UI. Keep Socket.io on a long-running Node server. Pairing Next.js (frontend) + Express socket server (API) is a common production pattern.
Do I need Redis on day one?
No. Start with one server process. Add Redis when you scale horizontally or need shared presence across instances.
How do I stop spam?
Auth + rate limits + max message length. For public communities, add moderation tools and ban lists next.
Is this production-ready as-is?
It is a solid foundation — not a full product. Production needs HTTPS, hardened rate limits, monitoring, backups, and UX polish (retries, offline state, accessibility).
Conclusion
You now have a full MERN + Socket.io chat path: models, JWT auth, message history, live rooms, and a React UI. Build it once, break it on purpose, then extend it — private rooms, typing indicators, or a client support widget.
If you want help turning this into a branded chat feature for your product or business site, I build full-stack apps with Node, React/Next.js, and real-time features for clients in India and abroad.
Next step: run both servers, open two browsers, send a message, and confirm it appears live. That single green path is your foundation for everything more advanced.
Related guides
Want a fast, SEO-friendly website for your business?
I build high-performance Next.js websites and web apps that load fast, rank on Google, and turn visitors into customers. Book a free, no-obligation consultation and let's talk about your project.