MERN Stack

Practical Knowledge Enhancement with MERN Stack, Building an Online Learning Platform

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

An online learning platform is a good practice project for the MERN stack because it needs everything a real product needs: accounts, roles, related data, pagination and a clean React front end. This guide builds a small one with MongoDB, Express 5, React and Node.js, where instructors publish courses, students enrol and comment, and the server enforces who may do what.

Key takeaways

Model users, courses, enrolments and comments in Mongoose, keep the role on the server, and check it on every write. Hash passwords with Argon2, keep the login token in an httpOnly cookie rather than localStorage, and paginate lists on the server. Build the React client with Vite, use a data-fetching library instead of hand-written Redux for server data, and deploy the finished app on a VPS or an app platform.

1. What you will build

Courses
Instructors create courses; anyone can browse a paginated catalogue
Enrolment
Signed-in students enrol and see their own course list
Comments
Enrolled students discuss each course, with live updates as an option

Use a current Node.js LTS release and MongoDB, either installed locally or a hosted MongoDB service. Keep the connection string and the token secret in a .env file that never goes into Git.

bash
mkdir learn-app && cd learn-app && mkdir server
cd server && npm init -y && npm pkg set type=module
npm install express mongoose argon2 jsonwebtoken cookie-parser express-rate-limit

2. The data models

Four collections are enough. The role lives only in the database: registration always creates a student, and you promote instructors by hand or from an admin screen.

javascript
// server/models.js
import mongoose from 'mongoose';
const { Schema, model, Types } = mongoose;

export const User = model('User', new Schema({
  email: { type: String, required: true, unique: true, lowercase: true, trim: true },
  name: { type: String, required: true, trim: true, maxlength: 80 },
  passwordHash: { type: String, required: true },
  role: { type: String, enum: ['student', 'instructor', 'admin'], default: 'student' },
}, { timestamps: true }));

export const Course = model('Course', new Schema({
  title: { type: String, required: true, trim: true, maxlength: 120 },
  description: { type: String, required: true, maxlength: 5000 },
  instructor: { type: Types.ObjectId, ref: 'User', required: true, index: true },
  published: { type: Boolean, default: false },
}, { timestamps: true }));

export const Enrolment = model('Enrolment', new Schema({
  user: { type: Types.ObjectId, ref: 'User', required: true },
  course: { type: Types.ObjectId, ref: 'Course', required: true },
}, { timestamps: true }).index({ user: 1, course: 1 }, { unique: true }));

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

A separate Enrolment collection with a unique index is better than pushing IDs into arrays on both the user and the course: it cannot drift out of sync, and it stops a double enrolment at the database level.

3. Registration, login and the auth middleware

Hash passwords with Argon2id, rate-limit the login routes, and send the token as an httpOnly cookie so page scripts cannot read it.

javascript
// server/auth.js
import { Router } from 'express';
import argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import { rateLimit } from 'express-rate-limit';
import { User } from './models.js';

const router = Router();
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10 });
const cookieOpts = { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 7 * 864e5 };

function issue(res, user) {
  const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, { expiresIn: '7d' });
  res.cookie('token', token, cookieOpts).json({ name: user.name, role: user.role });
}

router.post('/register', limiter, async (req, res) => {
  const { email, name, 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({ email, name, passwordHash: await argon2.hash(password) });
    issue(res.status(201), user);
  } catch {
    res.status(400).json({ error: 'Email already registered or invalid' });
  }
});

router.post('/login', limiter, async (req, res) => {
  const user = await User.findOne({ email: String(req.body.email).toLowerCase() });
  const ok = user && (await argon2.verify(user.passwordHash, String(req.body.password)));
  if (!ok) return res.status(401).json({ error: 'Invalid email or password' });
  issue(res, user);
});

router.post('/logout', (req, res) => res.clearCookie('token').status(204).end());

export async function requireUser(req, res, next) {
  try {
    const { sub } = jwt.verify(req.cookies.token ?? '', process.env.JWT_SECRET);
    req.user = await User.findById(sub);
    if (!req.user) throw new Error();
    next();
  } catch {
    res.status(401).json({ error: 'Sign in required' });
  }
}

export const requireRole = (...roles) => (req, res, next) =>
  roles.includes(req.user.role) ? next() : res.status(403).json({ error: 'Not allowed' });

export default router;

Loading the user from the database on each request means a role change or a deleted account takes effect immediately, not when the token expires.

4. Course, enrolment and comment routes

The catalogue is public and paginated on the server. Only instructors create courses, only the owner edits one, and only enrolled students comment.

javascript
// server/courses.js
import { Router } from 'express';
import { Course, Enrolment, Comment } from './models.js';
import { requireUser, requireRole } from './auth.js';

const router = Router();

router.get('/', async (req, res) => {
  const page = Math.max(1, parseInt(req.query.page) || 1);
  const limit = Math.min(50, parseInt(req.query.limit) || 12);
  const filter = { published: true };
  const [items, total] = await Promise.all([
    Course.find(filter).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit)
      .populate('instructor', 'name'),
    Course.countDocuments(filter),
  ]);
  res.json({ items, total, page, pages: Math.ceil(total / limit) });
});

router.post('/', requireUser, requireRole('instructor', 'admin'), async (req, res) => {
  const { title, description } = req.body;
  const course = await Course.create({ title, description, instructor: req.user.id });
  res.status(201).json(course);
});

router.patch('/:id', requireUser, async (req, res) => {
  const course = await Course.findById(req.params.id);
  if (!course) return res.status(404).json({ error: 'Not found' });
  if (!course.instructor.equals(req.user.id) && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Not your course' });
  }
  for (const key of ['title', 'description', 'published']) { // copy only allowed fields
    if (req.body[key] !== undefined) course[key] = req.body[key];
  }
  res.json(await course.save());
});

router.post('/:id/enrol', requireUser, async (req, res) => {
  const course = await Course.exists({ _id: req.params.id, published: true });
  if (!course) return res.status(404).json({ error: 'Not found' });
  try {
    await Enrolment.create({ user: req.user.id, course: req.params.id });
  } catch (err) {
    if (err.code !== 11000) throw err; // 11000 = already enrolled
  }
  res.status(204).end();
});

router.post('/:id/comments', requireUser, async (req, res) => {
  const enrolled = await Enrolment.exists({ user: req.user.id, course: req.params.id });
  if (!enrolled) return res.status(403).json({ error: 'Enrol to comment' });
  const comment = await Comment.create({ course: req.params.id, author: req.user.id, text: String(req.body.text).slice(0, 2000) });
  res.status(201).json(comment);
});

export default router;

Copying only named fields in the PATCH route matters: passing req.body straight into Mongoose would let a user change fields such as instructor. For large catalogues, switch from skip to cursor pagination (createdAt less than the last item's), which stays fast on deep pages.

5. The server entry point and error handling

Express 5 passes errors from async handlers to your error middleware automatically, so you no longer need a wrapper around every route.

javascript
// server/index.js   (run with: node --env-file=.env index.js)
import express from 'express';
import mongoose from 'mongoose';
import cookieParser from 'cookie-parser';
import auth from './auth.js';
import courses from './courses.js';

await mongoose.connect(process.env.MONGODB_URI);
const app = express();
app.set('trust proxy', 1);
app.use(express.json({ limit: '20kb' }), cookieParser());
app.use('/api/auth', auth);
app.use('/api/courses', courses);

app.use((err, req, res, next) => {
  if (err.name === 'ValidationError' || err.name === 'CastError') {
    return res.status(400).json({ error: 'Invalid input' });
  }
  console.error(err);
  res.status(500).json({ error: 'Something went wrong' });
});

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

Never send stack traces or database errors to the browser; log them on the server.

6. The React client

Create it with Vite (Create React App is deprecated) and add React Router and TanStack Query, which handles loading states, caching and refetching for server data:

bash
npm create vite@latest client -- --template react
cd client && npm install react-router @tanstack/react-query

In vite.config.js, proxy /api to http://localhost:3000 so the browser sees one origin and sends the cookie.

jsx
// client/src/Courses.jsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';

async function fetchCourses(page) {
  const res = await fetch(`/api/courses?page=${page}`);
  if (!res.ok) throw new Error('Could not load courses');
  return res.json();
}

export default function Courses() {
  const [page, setPage] = useState(1);
  const { data, isPending, error } = useQuery({ queryKey: ['courses', page], queryFn: () => fetchCourses(page) });

  if (isPending) return <p>Loading…</p>;
  if (error) return <p role="alert">{error.message}</p>;
  return (
    <section>
      {data.items.map((c) => (
        <article key={c._id}>
          <h3>{c.title}</h3>
          <p>{c.description}</p>
          <small>by {c.instructor.name}</small>
        </article>
      ))}
      <button disabled={page <= 1} onClick={() => setPage(page - 1)}>Previous</button>
      <button disabled={page >= data.pages} onClick={() => setPage(page + 1)}>Next</button>
    </section>
  );
}

React escapes text, so a comment containing script tags displays as plain text; never render user content with dangerouslySetInnerHTML. Hiding the "New course" button from students is only cosmetic: the server's role check is what protects the route. Wrap the app in a React error boundary so one failing component shows a message instead of a blank page.

7. Optional upgrades

FeatureCurrent approachReplaces in older tutorials
Server data in ReactTanStack QueryHand-written Redux actions and reducers
Global client stateReact context, or Redux Toolkit if it growsLegacy Redux createStore
Long listsTanStack Virtual or react-windowreact-virtualized
StylingTailwind CSS or a component library of your choiceBootstrap-only layouts
Live commentsSocket.IO 4 sharing the same cookie authUnauthenticated socket broadcasts
TestsVitest and React Testing Library; Supertest for the APIJest with a CRA setup

For live comments, emit an event to a room named after the course after the comment is saved, and authenticate the socket with the same cookie before letting it join. The MERN chat app tutorial shows the full pattern.

8. Running this on Domain India

Good fits
  • App Platform: Node.js is detected and built automatically; deploy with Deploy Now or a deploy token
  • VPS: full root access to run Node.js, MongoDB and nginx yourself
Watch out for
  • The App Platform includes PostgreSQL, not MongoDB, so pair it with a hosted MongoDB service
  • MongoDB's default port 27017 is not reachable from our shared hosting, so a MongoDB app does not belong there
  • A VPS is self-managed: you handle updates, security and backups

On the App Platform, your app must listen on the PORT environment variable and bind to 0.0.0.0; see Getting started with the App Platform. For a VPS, follow Running MERN/MEAN on a clean VPS and the VPS security checklist. If you would rather keep the course data relational, choosing between PostgreSQL, MySQL and MongoDB explains the trade-off.

App Developer
₹250/mo + GST
  • 512 MB RAM per app
  • 1.5 GB RAM total
  • 2 vCPU
  • 10 GB NVMe SSD
See plan details
VPS Starter
₹552.65/mo + GST
  • 1 vCPU
  • 2 GB DDR4 RAM
  • 64 GB NVMe SSD Storage
  • 2 TB Monthly Bandwidth
See plan details

Prices on the cards are Domain India list prices and exclude 18% GST.

Should I store the JWT in localStorage for a MERN app?

No. Any script running on the page can read localStorage, so one XSS bug leaks every token. Send the token in an httpOnly, SameSite cookie, which JavaScript cannot read, and serve the React app and the API from the same site.

How do I stop students creating courses?

Store the role in the user document, never in the request, and check it on the server for every write route. Load the user from the database on each request so role changes take effect at once. Hiding buttons in React protects nothing.

How should I model enrolments in MongoDB?

Use a separate Enrolment collection with a unique index on user and course. It prevents duplicate enrolments and cannot drift out of sync the way ID arrays stored on both the user and the course can.

Is skip and limit pagination good enough?

For small catalogues, yes. On large collections, skip gets slower on deep pages, so switch to cursor pagination that asks for items created before the last one you showed.

Can I host a MERN app on Domain India?

Yes. The App Platform detects and builds Node.js apps automatically and includes PostgreSQL, so use a hosted MongoDB service with it. On a VPS you install Node.js and MongoDB yourself. MongoDB is not reachable from shared hosting.

Ready to launch your course platform? Look at the App Platform for managed Node.js deploys, compare VPS plans for full control, or open a support ticket and tell us which fits your project.

Deploy your MERN app

Node.js apps are detected and built automatically, with free SSL and deploys from GitHub.

See the App Platform

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