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.
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
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:
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-markdownOlder 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.
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
// 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
// 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
// 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:
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.
| Option | Database | Good for |
|---|---|---|
| VPS | Install MongoDB yourself | Running the full MERN stack as written here |
| App Platform | PostgreSQL included; test MongoDB Atlas from the app | Letting us run the Node.js API for you |
| cPanel shared hosting | MySQL | React 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.
- 512 MB RAM per app
- 1 vCPU
- 5 GB NVMe SSD
- PostgreSQL Database
- 1 vCPU
- 2 GB DDR4 RAM
- 64 GB NVMe SSD Storage
- 2 TB Monthly Bandwidth
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.
The App Platform auto-detects Node.js apps and serves them over HTTPS, with a PostgreSQL database in every plan.
See App Platform plans