MERN Stack

Designing a Personal Blog Platform with MERN Stack

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

A personal blog is a good first full-stack project: it has users, content that belongs to them, public pages and private editing. This guide designs one with the MERN stack (MongoDB, Express, React and Node.js), focusing on the two parts beginners most often get wrong: authentication and managing user-generated content safely. The code uses current tools for 2026: a Node.js LTS release, Express 5, Mongoose, and React built with Vite.

Key takeaways

Model posts with an author, a unique slug and a draft or published status. Protect every write route with login middleware, and check that the post belongs to the signed-in user on every update and delete; never trust an author ID sent by the browser. Keep the session in an httpOnly cookie, validate input, and render post content without raw HTML. MongoDB does not run on shared hosting, so deploy the finished app on a VPS or the App Platform.

1. What we are building

Public blog
Anyone can read published posts, page through them and open a post by its slug.
Author accounts
Authors sign up, log in and log out, with the session in a secure cookie.
Editor
Authors create drafts, publish them, and edit or delete only their own posts.

The project has two folders: server for the Express API and client for the React app. In production, serve both from one domain (the app at /, the API at /api/) so the login cookie works without cross-site settings.

2. Set up the project

Install a current Node.js LTS release, then:

bash
mkdir mern-blog && cd mern-blog
mkdir server && cd server
npm init -y
npm install express mongoose bcryptjs jsonwebtoken cookie-parser helmet express-rate-limit
cd ..
npm create vite@latest client -- --template react
cd client && npm install react-markdown

Older tutorials use create-react-app, which is deprecated; Vite is the usual replacement. Keep secrets such as JWT_SECRET and MONGODB_URI in environment variables, never in the code or the Git repository.

3. Authentication, done properly

Authentication for a MERN app is covered step by step in developing the user authentication service: bcrypt password hashing, the token in an httpOnly cookie, rate-limited login and safe password reset. Build that first, including its requireAuth middleware: it verifies the session cookie, loads the user and sets req.user, or answers 401. The blog routes below use it as it is. The code here uses CommonJS (require) like that guide, so the two fit together.

Do not keep tokens in localStorage

Many older MERN tutorials return the JWT in the response body and store it in localStorage. Any script on the page can read localStorage, so one cross-site scripting bug hands over every session. An httpOnly cookie cannot be read by JavaScript. See JWT security best practices.

4. The post model

js
// server/models/Post.js
const mongoose = require('mongoose');

const postSchema = new mongoose.Schema(
  {
    title: { type: String, required: true, trim: true, maxlength: 200 },
    slug: { type: String, required: true, unique: true, lowercase: true },
    body: { type: String, required: true, maxlength: 100000 },
    tags: [{ type: String, trim: true, lowercase: true, maxlength: 40 }],
    status: { type: String, enum: ['draft', 'published'], default: 'draft' },
    author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
  },
  { timestamps: true }
);

postSchema.index({ status: 1, createdAt: -1 });
module.exports = mongoose.model('Post', postSchema);

The length limits stop one request from filling your database, the status field gives you drafts, and the index makes the public list fast. timestamps adds createdAt and updatedAt for you.

5. Routes for user-generated content

js
// server/routes/posts.js
const express = require('express');
const Post = require('../models/Post');
const requireAuth = require('../middleware/requireAuth');

const router = express.Router();
const slugify = (s) =>
  String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 80);

// Public: list published posts, 10 per page
router.get('/', async (req, res) => {
  const page = Math.max(1, Number.parseInt(req.query.page, 10) || 1);
  const posts = await Post.find({ status: 'published' })
    .sort({ createdAt: -1 })
    .skip((page - 1) * 10)
    .limit(10)
    .select('title slug tags createdAt author')
    .populate('author', 'name');
  res.json(posts);
});

// Public: one published post
router.get('/:slug', async (req, res) => {
  const post = await Post.findOne({ slug: String(req.params.slug), status: 'published' })
    .populate('author', 'name');
  if (!post) return res.status(404).json({ error: 'Not found' });
  res.json(post);
});

// Author: create. The author comes from the session, never from the body.
router.post('/', requireAuth, async (req, res) => {
  const { title, body, tags, status } = req.body;
  const slug = `${slugify(title)}-${Date.now().toString(36)}`;
  const post = await Post.create({ title, body, tags, status, slug, author: req.user._id });
  res.status(201).json(post);
});

// Author: update own post only
router.patch('/:id', requireAuth, async (req, res) => {
  const post = await Post.findOne({ _id: req.params.id, author: req.user._id });
  if (!post) return res.status(404).json({ error: 'Not found' });
  for (const field of ['title', 'body', 'tags', 'status']) {
    if (req.body[field] !== undefined) post[field] = req.body[field];
  }
  await post.save();
  res.json(post);
});

// Author: delete own post only
router.delete('/:id', requireAuth, async (req, res) => {
  const result = await Post.deleteOne({ _id: req.params.id, author: req.user._id });
  if (result.deletedCount === 0) return res.status(404).json({ error: 'Not found' });
  res.status(204).end();
});

module.exports = router;

Three rules make this safe. The author ID comes from the verified cookie, not the request body. Updates and deletes include author: req.user._id in the query, so one author cannot touch another's posts. And only the listed fields can be changed, so nobody can send author or slug in a PATCH and have it saved.

6. Wire up the server

js
// server/server.js
const express = require('express');
const mongoose = require('mongoose');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const authRoutes = require('./routes/auth');
const postRoutes = require('./routes/posts');

mongoose.set('sanitizeFilter', true); // blocks query-operator injection such as {"$gt": ""}

const app = express();
app.use(helmet());
app.use(express.json({ limit: '200kb' }));
app.use(cookieParser());
app.use('/api/auth', authRoutes);
app.use('/api/posts', postRoutes);

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

mongoose.connect(process.env.MONGODB_URI).then(() => {
  app.listen(process.env.PORT || 3000);
});

Express 5 passes errors from async route handlers to the error handler automatically, so you no longer need a try/catch in every route. For stricter input checks, validate req.body with a schema library such as Zod before it reaches Mongoose.

7. Show content safely in React

Blog posts are written by users, so treat them as untrusted. React escapes text by default; the risk comes from dangerouslySetInnerHTML or a Markdown renderer that allows raw HTML. react-markdown ignores raw HTML unless you add a plugin for it, so this is safe:

jsx
import Markdown from 'react-markdown';

export function PostBody({ body }) {
  return <Markdown>{body}</Markdown>;
}

If you ever need to render HTML from users, clean it with a sanitiser such as DOMPurify first. More in preventing XSS. During development, point Vite's dev server proxy at the API so /api calls work from the same origin.

8. Test the parts that matter

Use Vitest or Jest with Supertest for the API, and a throwaway test database. The tests worth writing first are the security ones: a logged-out user cannot create a post, author A cannot edit or delete author B's post, drafts do not appear in the public list, and a PATCH that sends author does not change it. Use React Testing Library for the front end.

9. Deploying it on Domain India

MongoDB is the part that decides where this app can run. Our shared hosting has no MongoDB server, and the outbound firewall on our cPanel and DirectAdmin servers does not open MongoDB's port 27017, so MongoDB is not available from shared hosting.

OptionDatabaseGood for
VPSInstall MongoDB yourselfRunning the full MERN stack as written here
App PlatformPostgreSQL included; test MongoDB Atlas from the appLetting us run the Node.js API for you
cPanel shared hostingMySQLReact and Express, if you swap MongoDB for MySQL

The App Platform auto-detects a Node.js app and builds it for you; deploy with Deploy Now or a deploy token from your terminal or CI. A VPS is self-managed, so the operating system, MongoDB, updates and backups are yours to run. The full comparison is in deploying a MERN app on Domain India.

App Starter
₹100/mo + GST
  • 512 MB RAM per app
  • 1 vCPU
  • 5 GB NVMe SSD
  • PostgreSQL Database
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
Is the MERN stack still a good choice for a blog in 2026?

Yes for learning full-stack JavaScript and for blogs with custom features. If you only want to publish articles, a CMS such as WordPress or a website builder is quicker, because the editor, SEO and security updates are already built.

Where should a MERN app store the login token?

In an httpOnly, Secure cookie with SameSite set to Lax or Strict. JavaScript cannot read an httpOnly cookie, so a cross-site scripting bug cannot steal the session. Avoid localStorage for tokens.

How do I stop one user from editing another user's posts?

Take the user ID from the verified session, never from the request body, and include it in the database query for every update and delete, for example findOne with both the post ID and author set to the signed-in user.

Should I still use create-react-app?

No. Create React App is deprecated. Start new React projects with Vite, or with a framework such as Next.js if you want server-side rendering.

Can I host a MERN app on Domain India shared hosting?

Not with MongoDB. Shared hosting has no MongoDB server and the outbound firewall does not open port 27017. Use a VPS, or the App Platform with its included PostgreSQL database, or swap MongoDB for MySQL on cPanel hosting.

Ready to deploy your blog? Read getting started with the App Platform, compare VPS plans, or open a support ticket if you are unsure which fits.

Run your MERN blog where it fits

The App Platform auto-detects Node.js apps and serves them over HTTPS, with a PostgreSQL database in every plan.

See App Platform 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