Back to blog

Express.js Working with MongoDB and Mongoose

expressnodejsmongodbmongoosebackend
Express.js Working with MongoDB and Mongoose

In the previous post, you mastered Express Router, request validation, and file uploads. You built clean route structures with validators and controllers — but everything lived in memory. Restart the server? Data gone.

Time to fix that. In this post, we'll connect your Express app to MongoDB using Mongoose — the most popular ODM (Object Data Modeling) library for Node.js. By the end, you'll have a fully persistent blog API with users, posts, and comments.

What You'll Learn

Learning Outcomes:
✅ Set up MongoDB locally and with MongoDB Atlas (cloud)
✅ Connect Express to MongoDB with proper error handling
✅ Define Mongoose schemas with validation and defaults
✅ Perform full CRUD operations (Create, Read, Update, Delete)
✅ Use virtuals, instance methods, and static methods
✅ Model relationships with references and population
✅ Implement pagination, filtering, and sorting
✅ Build a complete blog API with users, posts, and comments

Time commitment: 5–7 days, 1–2 hours daily
Prerequisites: Express.js Routing and Request Handling


Part 1: MongoDB Setup

What is MongoDB?

MongoDB is a document database — instead of rows and columns (like PostgreSQL or MySQL), it stores data as flexible JSON-like documents:

{
  "_id": "507f1f77bcf86cd799439011",
  "name": "Chanh Le",
  "email": "chanh@example.com",
  "posts": [
    { "title": "Getting Started with Express", "likes": 42 }
  ]
}

Key differences from SQL databases:

FeatureSQL (PostgreSQL)NoSQL (MongoDB)
Data modelTables with rowsCollections with documents
SchemaFixed, defined upfrontFlexible (schema-less by default)
RelationshipsJOINsEmbedding or references
Query languageSQLMongoDB Query Language
ScalingVertical (scale up)Horizontal (scale out)
Best forComplex relationships, transactionsFlexible schemas, rapid iteration

Installing MongoDB Locally

macOS:

brew tap mongodb/brew
brew install mongodb-community
brew services start mongodb-community

Linux (Ubuntu/Debian):

# Import MongoDB public GPG key
curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg --dearmor
 
# Add repository
echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list
 
sudo apt-get update
sudo apt-get install -y mongodb-org
sudo systemctl start mongod

Docker (recommended for development):

docker run -d --name mongodb \
  -p 27017:27017 \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=password \
  -v mongodb_data:/data/db \
  mongo:7

Verify connection:

mongosh
# Or with Docker:
docker exec -it mongodb mongosh -u admin -p password

MongoDB Atlas (Cloud)

For production or if you don't want to install locally:

  1. Go to mongodb.com/atlas and create a free account
  2. Create a free cluster (M0 tier — free forever)
  3. Set up database user and whitelist your IP
  4. Get the connection string: mongodb+srv://user:password@cluster.xxxxx.mongodb.net/mydb

Project Setup

mkdir express-blog-api && cd express-blog-api
npm init -y
npm install express mongoose dotenv
npm install -D nodemon

Create your .env file:

PORT=3000
MONGODB_URI=mongodb://localhost:27017/blog-api
# For Atlas: mongodb+srv://user:password@cluster.xxxxx.mongodb.net/blog-api

Connecting to MongoDB

Create src/config/database.js:

const mongoose = require('mongoose');
 
const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.MONGODB_URI);
    console.log(`MongoDB Connected: ${conn.connection.host}`);
  } catch (error) {
    console.error(`MongoDB connection error: ${error.message}`);
    process.exit(1);
  }
};
 
// Handle connection events
mongoose.connection.on('disconnected', () => {
  console.log('MongoDB disconnected');
});
 
mongoose.connection.on('error', (err) => {
  console.error(`MongoDB error: ${err}`);
});
 
// Graceful shutdown
process.on('SIGINT', async () => {
  await mongoose.connection.close();
  console.log('MongoDB connection closed (app termination)');
  process.exit(0);
});
 
module.exports = connectDB;

Create src/app.js:

require('dotenv').config();
const express = require('express');
const connectDB = require('./config/database');
 
const app = express();
 
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
 
// Connect to MongoDB
connectDB();
 
// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', db: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected' });
});
 
// Routes (we'll add these next)
// app.use('/api/users', require('./routes/users'));
// app.use('/api/posts', require('./routes/posts'));
 
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Update package.json:

{
  "scripts": {
    "start": "node src/app.js",
    "dev": "nodemon src/app.js"
  }
}

Part 2: Mongoose Schemas and Models

What is Mongoose?

Mongoose is an ODM (Object Data Modeling) library that provides a schema-based solution for MongoDB. While MongoDB is schema-less, Mongoose adds structure:

Defining a Schema

A schema defines the structure, types, and rules for your documents.

Create src/models/User.js:

const mongoose = require('mongoose');
 
const userSchema = new mongoose.Schema(
  {
    username: {
      type: String,
      required: [true, 'Username is required'],
      unique: true,
      trim: true,
      minlength: [3, 'Username must be at least 3 characters'],
      maxlength: [30, 'Username cannot exceed 30 characters'],
    },
    email: {
      type: String,
      required: [true, 'Email is required'],
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email'],
    },
    password: {
      type: String,
      required: [true, 'Password is required'],
      minlength: [8, 'Password must be at least 8 characters'],
      select: false, // Don't include in queries by default
    },
    bio: {
      type: String,
      maxlength: [500, 'Bio cannot exceed 500 characters'],
      default: '',
    },
    avatar: {
      type: String,
      default: 'default-avatar.png',
    },
    role: {
      type: String,
      enum: ['user', 'admin'],
      default: 'user',
    },
  },
  {
    timestamps: true, // Adds createdAt and updatedAt automatically
  }
);
 
module.exports = mongoose.model('User', userSchema);

Schema Types

Mongoose supports these types:

TypeJavaScriptExample
StringString"hello"
NumberNumber42, 3.14
BooleanBooleantrue, false
DateDatenew Date()
BufferBufferBinary data
ObjectIdObjectIdReferences to other documents
ArrayArray["a", "b"]
MapMapKey-value pairs
Schema.Types.MixedObjectAny type

Validation

Mongoose provides built-in and custom validators:

const postSchema = new mongoose.Schema({
  title: {
    type: String,
    required: [true, 'Title is required'],
    minlength: [5, 'Title must be at least 5 characters'],
    maxlength: [200, 'Title cannot exceed 200 characters'],
  },
  slug: {
    type: String,
    unique: true,
    lowercase: true,
  },
  content: {
    type: String,
    required: [true, 'Content is required'],
    minlength: [50, 'Content must be at least 50 characters'],
  },
  status: {
    type: String,
    enum: {
      values: ['draft', 'published', 'archived'],
      message: '{VALUE} is not a valid status',
    },
    default: 'draft',
  },
  tags: {
    type: [String],
    validate: {
      validator: function (v) {
        return v.length <= 10;
      },
      message: 'A post can have at most 10 tags',
    },
  },
  views: {
    type: Number,
    default: 0,
    min: [0, 'Views cannot be negative'],
  },
  // Custom validator
  publishedAt: {
    type: Date,
    validate: {
      validator: function (v) {
        return !v || v <= new Date();
      },
      message: 'Published date cannot be in the future',
    },
  },
});

Part 3: CRUD Operations

Creating Documents

const User = require('../models/User');
 
// Method 1: create()
const user = await User.create({
  username: 'johndoe',
  email: 'john@example.com',
  password: 'securepassword123',
  bio: 'Full-stack developer',
});
 
// Method 2: new + save()
const user = new User({
  username: 'janedoe',
  email: 'jane@example.com',
  password: 'securepassword456',
});
await user.save();
 
// Method 3: insertMany (bulk)
const users = await User.insertMany([
  { username: 'user1', email: 'user1@example.com', password: 'pass12345678' },
  { username: 'user2', email: 'user2@example.com', password: 'pass12345678' },
]);

Reading Documents

// Find all
const users = await User.find();
 
// Find with conditions
const admins = await User.find({ role: 'admin' });
 
// Find one
const user = await User.findOne({ email: 'john@example.com' });
 
// Find by ID
const user = await User.findById('507f1f77bcf86cd799439011');
 
// Select specific fields
const user = await User.findById(id).select('username email bio -_id');
 
// Query operators
const recentUsers = await User.find({
  createdAt: { $gte: new Date('2024-01-01') },
  role: { $in: ['user', 'admin'] },
});
 
// Sorting
const users = await User.find().sort({ createdAt: -1 }); // Newest first
const users = await User.find().sort('username');          // Alphabetical
 
// Limiting and skipping (pagination)
const page = 1;
const limit = 10;
const users = await User.find()
  .sort({ createdAt: -1 })
  .skip((page - 1) * limit)
  .limit(limit);
 
// Count documents
const totalUsers = await User.countDocuments({ role: 'user' });

Updating Documents

// findByIdAndUpdate (returns the document)
const user = await User.findByIdAndUpdate(
  id,
  { bio: 'Updated bio', role: 'admin' },
  {
    new: true,           // Return the updated document (not the old one)
    runValidators: true, // Run schema validators on update
  }
);
 
// findOneAndUpdate
const user = await User.findOneAndUpdate(
  { email: 'john@example.com' },
  { $set: { bio: 'New bio' } },
  { new: true, runValidators: true }
);
 
// updateOne (doesn't return the document)
await User.updateOne(
  { _id: id },
  { $set: { role: 'admin' } }
);
 
// updateMany (bulk update)
await User.updateMany(
  { role: 'user' },
  { $set: { verified: true } }
);
 
// Atomic operators
await User.findByIdAndUpdate(id, {
  $inc: { loginCount: 1 },    // Increment
  $push: { tags: 'new-tag' }, // Add to array
  $pull: { tags: 'old-tag' }, // Remove from array
  $addToSet: { followers: userId }, // Add unique to array
});

Deleting Documents

// findByIdAndDelete
const user = await User.findByIdAndDelete(id);
if (!user) {
  throw new Error('User not found');
}
 
// findOneAndDelete
const user = await User.findOneAndDelete({ email: 'john@example.com' });
 
// deleteOne
await User.deleteOne({ _id: id });
 
// deleteMany
const result = await User.deleteMany({ role: 'inactive' });
console.log(`Deleted ${result.deletedCount} users`);

Part 4: Schema Features

Virtuals

Virtuals are properties that aren't stored in MongoDB but computed from existing fields:

const userSchema = new mongoose.Schema({
  firstName: String,
  lastName: String,
});
 
// Virtual property: fullName
userSchema.virtual('fullName').get(function () {
  return `${this.firstName} ${this.lastName}`;
});
 
// Include virtuals in JSON output
userSchema.set('toJSON', { virtuals: true });
userSchema.set('toObject', { virtuals: true });
 
const user = new User({ firstName: 'Chanh', lastName: 'Le' });
console.log(user.fullName); // "Chanh Le"

Instance Methods

Methods available on document instances:

const userSchema = new mongoose.Schema({
  email: String,
  password: String,
});
 
// Instance method: compare passwords
userSchema.methods.comparePassword = async function (candidatePassword) {
  const bcrypt = require('bcrypt');
  return bcrypt.compare(candidatePassword, this.password);
};
 
// Instance method: generate profile summary
userSchema.methods.getPublicProfile = function () {
  return {
    id: this._id,
    username: this.username,
    email: this.email,
    bio: this.bio,
    avatar: this.avatar,
    createdAt: this.createdAt,
  };
};
 
// Usage
const user = await User.findById(id).select('+password');
const isMatch = await user.comparePassword('mypassword');
const profile = user.getPublicProfile();

Static Methods

Methods available on the model itself:

// Static method: find by email
userSchema.statics.findByEmail = function (email) {
  return this.findOne({ email: email.toLowerCase() });
};
 
// Static method: get active users with pagination
userSchema.statics.getActiveUsers = function (page = 1, limit = 10) {
  return this.find({ role: { $ne: 'inactive' } })
    .sort({ createdAt: -1 })
    .skip((page - 1) * limit)
    .limit(limit);
};
 
// Usage
const user = await User.findByEmail('john@example.com');
const activeUsers = await User.getActiveUsers(2, 20);

Middleware (Hooks)

Run functions before or after certain operations:

const bcrypt = require('bcrypt');
 
// Pre-save: hash password before saving
userSchema.pre('save', async function (next) {
  // Only hash if password was modified
  if (!this.isModified('password')) return next();
 
  const salt = await bcrypt.genSalt(12);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});
 
// Pre-save: generate slug from title
postSchema.pre('save', function (next) {
  if (this.isModified('title')) {
    this.slug = this.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '');
  }
  next();
});
 
// Post-save: log creation
userSchema.post('save', function (doc) {
  console.log(`New user created: ${doc.email}`);
});
 
// Pre-find: exclude inactive by default
userSchema.pre(/^find/, function (next) {
  this.where({ active: { $ne: false } });
  next();
});

Indexes

Indexes speed up queries — essential for production:

const postSchema = new mongoose.Schema({
  title: String,
  slug: { type: String, unique: true },        // Unique index
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  tags: [String],
  status: String,
  createdAt: Date,
});
 
// Single field index
postSchema.index({ createdAt: -1 });
 
// Compound index (for querying by author + status together)
postSchema.index({ author: 1, status: 1 });
 
// Text index (for full-text search)
postSchema.index({ title: 'text', content: 'text' });
 
// Usage with text search
const results = await Post.find({ $text: { $search: 'express mongodb' } });

Part 5: Relationships and Population

MongoDB doesn't have JOINs, but Mongoose provides population — a way to automatically replace references with actual documents.

Referenced Relationships

Create src/models/Post.js:

const mongoose = require('mongoose');
 
const postSchema = new mongoose.Schema(
  {
    title: {
      type: String,
      required: [true, 'Title is required'],
      trim: true,
      maxlength: [200, 'Title cannot exceed 200 characters'],
    },
    slug: {
      type: String,
      unique: true,
      lowercase: true,
    },
    content: {
      type: String,
      required: [true, 'Content is required'],
    },
    excerpt: {
      type: String,
      maxlength: [500, 'Excerpt cannot exceed 500 characters'],
    },
    author: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'User',
      required: [true, 'Author is required'],
    },
    tags: {
      type: [String],
      default: [],
    },
    status: {
      type: String,
      enum: ['draft', 'published', 'archived'],
      default: 'draft',
    },
    views: {
      type: Number,
      default: 0,
    },
    publishedAt: Date,
  },
  {
    timestamps: true,
    toJSON: { virtuals: true },
    toObject: { virtuals: true },
  }
);
 
// Virtual: comments for this post
postSchema.virtual('comments', {
  ref: 'Comment',
  localField: '_id',
  foreignField: 'post',
});
 
// Pre-save: generate slug
postSchema.pre('save', function (next) {
  if (this.isModified('title')) {
    this.slug = this.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '');
  }
  if (this.isModified('status') && this.status === 'published' && !this.publishedAt) {
    this.publishedAt = new Date();
  }
  next();
});
 
// Indexes
postSchema.index({ slug: 1 });
postSchema.index({ author: 1, status: 1 });
postSchema.index({ tags: 1 });
postSchema.index({ createdAt: -1 });
 
module.exports = mongoose.model('Post', postSchema);

Create src/models/Comment.js:

const mongoose = require('mongoose');
 
const commentSchema = new mongoose.Schema(
  {
    content: {
      type: String,
      required: [true, 'Comment content is required'],
      maxlength: [2000, 'Comment cannot exceed 2000 characters'],
    },
    post: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Post',
      required: [true, 'Post reference is required'],
    },
    author: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'User',
      required: [true, 'Author is required'],
    },
  },
  {
    timestamps: true,
  }
);
 
// Index for efficient queries
commentSchema.index({ post: 1, createdAt: -1 });
 
module.exports = mongoose.model('Comment', commentSchema);

Population (Replacing References with Documents)

// Basic population
const post = await Post.findById(id).populate('author');
// post.author is now the full User document, not just an ObjectId
 
// Select specific fields from populated document
const post = await Post.findById(id).populate('author', 'username email avatar');
 
// Multiple populations
const post = await Post.findById(id)
  .populate('author', 'username email avatar')
  .populate({
    path: 'comments',
    populate: { path: 'author', select: 'username avatar' }, // Nested population
    options: { sort: { createdAt: -1 }, limit: 10 },
  });
 
// Population with conditions
const posts = await Post.find({ status: 'published' })
  .populate({
    path: 'author',
    match: { role: 'admin' }, // Only populate if author is admin
    select: 'username email',
  })
  .sort({ publishedAt: -1 });

Embedded Documents vs References

When to embed vs reference?

Embed (subdocument)Reference (ObjectId + populate)
Data is always accessed togetherData is accessed independently
One-to-few relationship (< 100)One-to-many or many-to-many
Data doesn't change oftenData changes frequently
Example: address in userExample: comments on a post

Embedded example:

const userSchema = new mongoose.Schema({
  username: String,
  // Embedded: address is always fetched with user
  address: {
    street: String,
    city: String,
    country: String,
    zipCode: String,
  },
  // Embedded array: social links
  socialLinks: [
    {
      platform: { type: String, enum: ['twitter', 'github', 'linkedin'] },
      url: String,
    },
  ],
});

Part 6: Building the Blog API

Let's bring everything together into a complete API.

Project Structure

src/
├── app.js
├── config/
│   └── database.js
├── models/
│   ├── User.js
│   ├── Post.js
│   └── Comment.js
├── routes/
│   ├── users.js
│   ├── posts.js
│   └── comments.js
├── controllers/
│   ├── userController.js
│   ├── postController.js
│   └── commentController.js
└── middleware/
    └── errorHandler.js

Error Handler Middleware

Create src/middleware/errorHandler.js:

const errorHandler = (err, req, res, next) => {
  console.error(err.stack);
 
  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const errors = Object.values(err.errors).map((e) => e.message);
    return res.status(400).json({ error: 'Validation failed', details: errors });
  }
 
  // Mongoose duplicate key error
  if (err.code === 11000) {
    const field = Object.keys(err.keyPattern)[0];
    return res.status(409).json({ error: `${field} already exists` });
  }
 
  // Mongoose cast error (invalid ObjectId)
  if (err.name === 'CastError') {
    return res.status(400).json({ error: `Invalid ${err.path}: ${err.value}` });
  }
 
  res.status(err.statusCode || 500).json({
    error: err.message || 'Internal server error',
  });
};
 
module.exports = errorHandler;

User Routes and Controller

Create src/controllers/userController.js:

const User = require('../models/User');
 
exports.createUser = async (req, res, next) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json({ data: user.getPublicProfile() });
  } catch (error) {
    next(error);
  }
};
 
exports.getUsers = async (req, res, next) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
 
    const [users, total] = await Promise.all([
      User.find()
        .select('-password')
        .sort({ createdAt: -1 })
        .skip((page - 1) * limit)
        .limit(limit),
      User.countDocuments(),
    ]);
 
    res.json({
      data: users,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit),
      },
    });
  } catch (error) {
    next(error);
  }
};
 
exports.getUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id).select('-password');
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json({ data: user });
  } catch (error) {
    next(error);
  }
};
 
exports.updateUser = async (req, res, next) => {
  try {
    // Prevent password and role updates through this endpoint
    const { password, role, ...updateData } = req.body;
 
    const user = await User.findByIdAndUpdate(req.params.id, updateData, {
      new: true,
      runValidators: true,
    }).select('-password');
 
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json({ data: user });
  } catch (error) {
    next(error);
  }
};
 
exports.deleteUser = async (req, res, next) => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

Create src/routes/users.js:

const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
 
router.route('/')
  .get(userController.getUsers)
  .post(userController.createUser);
 
router.route('/:id')
  .get(userController.getUser)
  .patch(userController.updateUser)
  .delete(userController.deleteUser);
 
module.exports = router;

Post Routes and Controller

Create src/controllers/postController.js:

const Post = require('../models/Post');
 
exports.createPost = async (req, res, next) => {
  try {
    const post = await Post.create(req.body);
    await post.populate('author', 'username email avatar');
    res.status(201).json({ data: post });
  } catch (error) {
    next(error);
  }
};
 
exports.getPosts = async (req, res, next) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
 
    // Build filter
    const filter = {};
    if (req.query.status) filter.status = req.query.status;
    if (req.query.author) filter.author = req.query.author;
    if (req.query.tag) filter.tags = req.query.tag;
    if (req.query.search) {
      filter.$or = [
        { title: { $regex: req.query.search, $options: 'i' } },
        { content: { $regex: req.query.search, $options: 'i' } },
      ];
    }
 
    // Build sort
    let sort = { createdAt: -1 };
    if (req.query.sort === 'views') sort = { views: -1 };
    if (req.query.sort === 'oldest') sort = { createdAt: 1 };
    if (req.query.sort === 'title') sort = { title: 1 };
 
    const [posts, total] = await Promise.all([
      Post.find(filter)
        .populate('author', 'username email avatar')
        .sort(sort)
        .skip((page - 1) * limit)
        .limit(limit),
      Post.countDocuments(filter),
    ]);
 
    res.json({
      data: posts,
      pagination: { page, limit, total, pages: Math.ceil(total / limit) },
    });
  } catch (error) {
    next(error);
  }
};
 
exports.getPost = async (req, res, next) => {
  try {
    const post = await Post.findOne({ slug: req.params.slug })
      .populate('author', 'username email avatar')
      .populate({
        path: 'comments',
        populate: { path: 'author', select: 'username avatar' },
        options: { sort: { createdAt: -1 } },
      });
 
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
 
    // Increment views
    post.views += 1;
    await post.save({ validateBeforeSave: false });
 
    res.json({ data: post });
  } catch (error) {
    next(error);
  }
};
 
exports.updatePost = async (req, res, next) => {
  try {
    const post = await Post.findByIdAndUpdate(req.params.id, req.body, {
      new: true,
      runValidators: true,
    }).populate('author', 'username email avatar');
 
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
    res.json({ data: post });
  } catch (error) {
    next(error);
  }
};
 
exports.deletePost = async (req, res, next) => {
  try {
    const post = await Post.findByIdAndDelete(req.params.id);
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
 
    // Also delete all comments for this post
    const Comment = require('../models/Comment');
    await Comment.deleteMany({ post: req.params.id });
 
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

Create src/routes/posts.js:

const express = require('express');
const router = express.Router();
const postController = require('../controllers/postController');
 
router.route('/')
  .get(postController.getPosts)
  .post(postController.createPost);
 
router.get('/:slug', postController.getPost);
 
router.route('/:id')
  .patch(postController.updatePost)
  .delete(postController.deletePost);
 
module.exports = router;

Comment Routes and Controller

Create src/controllers/commentController.js:

const Comment = require('../models/Comment');
 
exports.createComment = async (req, res, next) => {
  try {
    const comment = await Comment.create({
      ...req.body,
      post: req.params.postId,
    });
    await comment.populate('author', 'username avatar');
    res.status(201).json({ data: comment });
  } catch (error) {
    next(error);
  }
};
 
exports.getComments = async (req, res, next) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 20;
 
    const filter = { post: req.params.postId };
 
    const [comments, total] = await Promise.all([
      Comment.find(filter)
        .populate('author', 'username avatar')
        .sort({ createdAt: -1 })
        .skip((page - 1) * limit)
        .limit(limit),
      Comment.countDocuments(filter),
    ]);
 
    res.json({
      data: comments,
      pagination: { page, limit, total, pages: Math.ceil(total / limit) },
    });
  } catch (error) {
    next(error);
  }
};
 
exports.deleteComment = async (req, res, next) => {
  try {
    const comment = await Comment.findByIdAndDelete(req.params.id);
    if (!comment) {
      return res.status(404).json({ error: 'Comment not found' });
    }
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

Create src/routes/comments.js:

const express = require('express');
const router = express.Router({ mergeParams: true }); // Access postId from parent
const commentController = require('../controllers/commentController');
 
router.route('/')
  .get(commentController.getComments)
  .post(commentController.createComment);
 
router.delete('/:id', commentController.deleteComment);
 
module.exports = router;

Wire Everything in app.js

Update src/app.js:

require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const connectDB = require('./config/database');
const errorHandler = require('./middleware/errorHandler');
 
const app = express();
 
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
 
// Connect to MongoDB
connectDB();
 
// Health check
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    db: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected',
  });
});
 
// Routes
app.use('/api/users', require('./routes/users'));
app.use('/api/posts', require('./routes/posts'));
app.use('/api/posts/:postId/comments', require('./routes/comments'));
 
// Error handler (must be last)
app.use(errorHandler);
 
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Testing the API

# Start the server
npm run dev
 
# Create a user
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"username": "chanh", "email": "chanh@example.com", "password": "password123"}'
 
# Create a post (use the user _id from above)
curl -X POST http://localhost:3000/api/posts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Getting Started with Express",
    "content": "Express.js is a minimal and flexible Node.js web framework...",
    "author": "USER_ID_HERE",
    "tags": ["express", "nodejs"],
    "status": "published"
  }'
 
# Get all published posts
curl "http://localhost:3000/api/posts?status=published&page=1&limit=5"
 
# Get a single post by slug
curl http://localhost:3000/api/posts/getting-started-with-express
 
# Add a comment
curl -X POST http://localhost:3000/api/posts/POST_ID_HERE/comments \
  -H "Content-Type: application/json" \
  -d '{"content": "Great article!", "author": "USER_ID_HERE"}'
 
# Search posts
curl "http://localhost:3000/api/posts?search=express"
 
# Update a post
curl -X PATCH http://localhost:3000/api/posts/POST_ID_HERE \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated Title"}'
 
# Delete a post (also deletes its comments)
curl -X DELETE http://localhost:3000/api/posts/POST_ID_HERE

Exercises

Exercise 1: Add Likes to Posts

Add a likes feature to the Post model:

  • Track which users liked a post (array of user IDs)
  • Add POST /api/posts/:id/like and DELETE /api/posts/:id/like endpoints
  • Prevent duplicate likes (use $addToSet)
  • Add a virtual for likeCount

Exercise 2: User Posts Aggregation

Create an endpoint GET /api/users/:id/stats that returns:

  • Total number of posts
  • Total views across all posts
  • Number of published vs draft posts
  • Most popular post (by views)

Use Mongoose's aggregate() pipeline.

Implement search across posts:

  • Create a text index on title and content
  • Add search scoring to sort by relevance
  • Return highlighted snippets (first 200 chars of matching content)
  • Support filtering by tags alongside search

What's Next?

In the next post, we'll work with PostgreSQL and Prisma — a completely different approach to database integration:

  • Setting up PostgreSQL with Express
  • Prisma ORM: schema definition and migrations
  • Type-safe queries with Prisma Client
  • Comparing Mongoose (MongoDB) vs Prisma (PostgreSQL)
  • When to choose SQL vs NoSQL

Everything you learned here — schemas, validation, relationships — applies to SQL too, just with different syntax.


Summary and Key Takeaways

✅ MongoDB stores flexible JSON-like documents — great for rapid development and evolving schemas
✅ Mongoose adds structure to MongoDB with schemas, validation, and middleware hooks
✅ Always use timestamps: true in schemas for automatic createdAt and updatedAt
✅ Use select: false on sensitive fields (like passwords) to exclude them from queries by default
✅ Pre-save middleware is perfect for hashing passwords and generating slugs
✅ Population replaces ObjectId references with full documents — use it for relationships
✅ Embed data that's always accessed together; reference data that's accessed independently
✅ Always add indexes for fields you frequently query, sort, or filter by
✅ Use runValidators: true in update operations — Mongoose skips validation on updates by default
✅ Handle Mongoose-specific errors (ValidationError, CastError, duplicate key) in your error middleware


Series: Express.js Learning Roadmap
Previous: Routing and Request Handling
Next: Working with PostgreSQL and Prisma (Coming Soon)


Have questions about MongoDB or Mongoose? Feel free to reach out or leave a comment!

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.