With the endpoint map designed and the project structure in place, it is time to implement the full CRUD API for the blog posts resource. This lesson brings together Express routing, Mongoose models, controller functions, pagination, filtering, and consistent response shapes into a working API you can test immediately with Postman. By the end you will have a complete posts API โ the backbone of the MERN Blog โ ready to be connected to React in Part 5 of the series.
The Post Mongoose Model
// server/src/models/Post.js
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema(
{
title: {
type: String,
required: [true, 'Post title is required'],
trim: true,
minlength: [3, 'Title must be at least 3 characters'],
maxlength: [200, 'Title cannot exceed 200 characters'],
},
slug: {
type: String,
unique: true,
lowercase: true,
},
body: {
type: String,
required: [true, 'Post body is required'],
minlength: [10, 'Body must be at least 10 characters'],
},
excerpt: {
type: String,
maxlength: [500, 'Excerpt cannot exceed 500 characters'],
},
coverImage: { type: String },
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
tags: { type: [String], default: [] },
published: { type: Boolean, default: false },
featured: { type: Boolean, default: false },
viewCount: { type: Number, default: 0 },
},
{ timestamps: true } // adds createdAt and updatedAt automatically
);
// Auto-generate slug from title before saving
postSchema.pre('save', function (next) {
if (this.isModified('title') || this.isNew) {
this.slug = this.title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
next();
});
// Index for common query patterns
postSchema.index({ author: 1, createdAt: -1 });
postSchema.index({ tags: 1 });
postSchema.index({ slug: 1 }, { unique: true });
postSchema.index({ published: 1, createdAt: -1 });
module.exports = mongoose.model('Post', postSchema);
{ timestamps: true } option in the schema definition automatically adds createdAt and updatedAt fields to every document and updates updatedAt every time you call save(), findByIdAndUpdate(), or any other update method. You never need to manage these fields manually โ Mongoose handles them for you.author, tags, slug (unique), and published + createdAt for the main feed query.findByIdAndUpdate() with { new: true } returns the updated document, but it bypasses Mongoose middleware (pre/post hooks) and validators defined in the schema by default. To run validators on update, pass { runValidators: true } as well. Always include both options for update operations: { new: true, runValidators: true }.Full CRUD Controller
// server/src/controllers/postController.js
const Post = require('../models/Post');
const AppError = require('../utils/AppError');
const asyncHandler = require('../utils/asyncHandler');
// โโ GET /api/posts โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const getAllPosts = asyncHandler(async (req, res) => {
const {
page = 1, limit = 10, sort = 'createdAt', order = 'desc',
tag, author, search, published = 'true',
} = req.query;
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const limitNum = Math.min(100, parseInt(limit, 10) || 10);
const skip = (pageNum - 1) * limitNum;
const sortDir = order === 'asc' ? 1 : -1;
const filter = {};
// Only admins can see unpublished posts in the list
if (!req.user || req.user.role !== 'admin') filter.published = true;
else if (published !== undefined) filter.published = published === 'true';
if (tag) filter.tags = tag;
if (author) filter.author = author;
if (search) filter.$text = { $search: search };
const [posts, total] = await Promise.all([
Post.find(filter)
.sort({ [sort]: sortDir })
.skip(skip)
.limit(limitNum)
.populate('author', 'name avatar'),
Post.countDocuments(filter),
]);
res.json({
success: true,
count: posts.length,
total,
page: pageNum,
pages: Math.ceil(total / limitNum),
data: posts,
});
});
// โโ GET /api/posts/:id โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const getPostById = asyncHandler(async (req, res) => {
const post = await Post.findById(req.params.id)
.populate('author', 'name avatar bio');
if (!post) throw new AppError('Post not found', 404);
if (!post.published && (!req.user || req.user.id !== post.author.id)) {
throw new AppError('Post not found', 404); // hide unpublished from non-owners
}
// Increment view count (fire-and-forget โ do not await)
Post.findByIdAndUpdate(req.params.id, { $inc: { viewCount: 1 } }).exec();
res.json({ success: true, data: post });
});
// โโ POST /api/posts โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const createPost = asyncHandler(async (req, res) => {
const { title, body, excerpt, tags, published, featured, coverImage } = req.body;
const post = await Post.create({
title, body, excerpt, tags, published, featured, coverImage,
author: req.user.id, // set from JWT middleware โ never from req.body
});
res.status(201).json({ success: true, data: post });
});
// โโ PATCH /api/posts/:id โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const updatePost = asyncHandler(async (req, res) => {
let post = await Post.findById(req.params.id);
if (!post) throw new AppError('Post not found', 404);
// Ownership check โ only the author or an admin can update
if (post.author.toString() !== req.user.id && req.user.role !== 'admin') {
throw new AppError('Not authorised to update this post', 403);
}
// Only update the fields that were sent โ never allow author to be changed
const { title, body, excerpt, tags, published, featured, coverImage } = req.body;
const updates = { title, body, excerpt, tags, published, featured, coverImage };
// Remove undefined fields so unset fields are not overwritten with undefined
Object.keys(updates).forEach(key => updates[key] === undefined && delete updates[key]);
post = await Post.findByIdAndUpdate(
req.params.id,
updates,
{ new: true, runValidators: true }
).populate('author', 'name avatar');
res.json({ success: true, data: post });
});
// โโ DELETE /api/posts/:id โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const deletePost = asyncHandler(async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) throw new AppError('Post not found', 404);
if (post.author.toString() !== req.user.id && req.user.role !== 'admin') {
throw new AppError('Not authorised to delete this post', 403);
}
await post.deleteOne();
res.json({ success: true, message: 'Post deleted successfully' });
});
module.exports = { getAllPosts, getPostById, createPost, updatePost, deletePost };
Posts Router
// server/src/routes/posts.js
const express = require('express');
const router = express.Router();
const {
getAllPosts, getPostById, createPost, updatePost, deletePost,
} = require('../controllers/postController');
const protect = require('../middleware/auth');
const validateObjectId = require('../middleware/validateObjectId');
// Public routes
router.get('/featured', asyncHandler(async (req, res) => {
const posts = await Post.find({ published: true, featured: true })
.sort({ createdAt: -1 }).limit(6).populate('author','name avatar');
res.json({ success: true, count: posts.length, data: posts });
}));
router.route('/')
.get(getAllPosts)
.post(protect, createPost);
router.route('/:id')
.get(validateObjectId('id'), getPostById)
.patch(protect, validateObjectId('id'), updatePost)
.delete(protect, validateObjectId('id'), deletePost);
module.exports = router;
Pagination Response Pattern
GET /api/posts?page=2&limit=5
Response:
{
"success": true,
"count": 5, โ items in this page
"total": 47, โ total matching documents in MongoDB
"page": 2, โ current page
"pages": 10, โ total pages (ceil(47/5))
"data": [
{ "_id": "...", "title": "...", "author": { "name": "Jane" }, ... },
...
]
}
React usage:
Fetch page 1 on mount
Show "Load more" button if page < pages
On click: fetch page + 1 and append to existing posts array
Common Mistakes
Mistake 1 โ Setting author from req.body instead of req.user
โ Wrong โ any user can claim to be any author:
const post = await Post.create({ ...req.body }); // req.body.author could be anyone's ID
โ Correct โ always set author from the verified JWT, not from the client:
const { title, body, tags } = req.body; // destructure only safe fields
const post = await Post.create({ title, body, tags, author: req.user.id }); // โ
Mistake 2 โ Not running validators on update operations
โ Wrong โ bypassing schema validation on updates:
await Post.findByIdAndUpdate(id, req.body); // validators do NOT run by default
โ Correct โ always include runValidators:
await Post.findByIdAndUpdate(id, updates, { new: true, runValidators: true }); // โ
Mistake 3 โ Skipping the ownership check on update and delete
โ Wrong โ any authenticated user can update or delete any post:
const updatePost = asyncHandler(async (req, res) => {
const post = await Post.findByIdAndUpdate(req.params.id, req.body, { new: true });
res.json({ data: post }); // no ownership check!
});
โ Correct โ always verify the requesting user owns the resource:
if (post.author.toString() !== req.user.id && req.user.role !== 'admin') {
throw new AppError('Not authorised', 403);
}
Quick Reference
| Operation | Mongoose Method | Options |
|---|---|---|
| Get all (filter) | Post.find(filter).sort().skip().limit() |
populate, lean |
| Get one | Post.findById(id) |
populate, select |
| Count | Post.countDocuments(filter) |
โ |
| Create | Post.create({ fields }) |
โ |
| Update (patch) | Post.findByIdAndUpdate(id, updates, opts) |
new: true, runValidators: true |
| Delete | await post.deleteOne() |
โ |
| Parallel queries | Promise.all([Post.find(), Post.countDocuments()]) |
โ |