DEV Community

Cover image for Building a Real-Time Chat App with MERN and Socket.io (Complete Guide)
Vishvajeet Shukla
Vishvajeet Shukla

Posted on Originally published at vishvajeetshukla.in

Building a Real-Time Chat App with MERN and Socket.io (Complete Guide)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode
npm run dev
Enter fullscreen mode Exit fullscreen mode

Step 3 — React Client

cd ..
npm create vite@latest client -- --template react
cd client
npm install
npm install socket.io-client
npm run dev
Enter fullscreen mode Exit fullscreen mode

Set the API base in client/.env:

VITE_API_URL=http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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

  1. User logs in → server returns JWT.
  2. React stores JWT and opens a Socket.io connection with auth: { token }.
  3. Server verifies JWT before accepting the socket.
  4. Client joins room general.
  5. Client loads last 50 messages via REST (so refresh is not empty).
  6. On send, server saves MongoDB document, then io.to(room).emit('new_message').
  7. 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)

  1. MongoDB Atlas free cluster → copy URI into server env.
  2. Deploy server to Railway / Render / a VPS (needs sticky sessions or Redis adapter for multi-instance).
  3. Deploy client to Vercel/Netlify with VITE_API_URL pointing to your API.
  4. Update CLIENT_URL on 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


Originally published at vishvajeetshukla.in

Top comments (0)