Implementing Full CRUD Endpoints with Express and Mongoose

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);
Note: The { 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.
Tip: Add database indexes for every field you query or sort by frequently. Without an index, MongoDB scans every document in the collection for each query โ€” this is fast for small datasets but catastrophically slow at scale. The most important indexes for a blog API are on author, tags, slug (unique), and published + createdAt for the main feed query.
Warning: 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()]) โ€”

🧠 Test Yourself

In your updatePost controller you call Post.findByIdAndUpdate(id, req.body, { new: true }). A user sends { "author": "anotherUserId", "title": "Hacked" }. What are the two problems with this code?