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:
| Feature | SQL (PostgreSQL) | NoSQL (MongoDB) |
|---|---|---|
| Data model | Tables with rows | Collections with documents |
| Schema | Fixed, defined upfront | Flexible (schema-less by default) |
| Relationships | JOINs | Embedding or references |
| Query language | SQL | MongoDB Query Language |
| Scaling | Vertical (scale up) | Horizontal (scale out) |
| Best for | Complex relationships, transactions | Flexible schemas, rapid iteration |
Installing MongoDB Locally
macOS:
brew tap mongodb/brew
brew install mongodb-community
brew services start mongodb-communityLinux (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 mongodDocker (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:7Verify connection:
mongosh
# Or with Docker:
docker exec -it mongodb mongosh -u admin -p passwordMongoDB Atlas (Cloud)
For production or if you don't want to install locally:
- Go to mongodb.com/atlas and create a free account
- Create a free cluster (M0 tier — free forever)
- Set up database user and whitelist your IP
- 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 nodemonCreate your .env file:
PORT=3000
MONGODB_URI=mongodb://localhost:27017/blog-api
# For Atlas: mongodb+srv://user:password@cluster.xxxxx.mongodb.net/blog-apiConnecting 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:
| Type | JavaScript | Example |
|---|---|---|
String | String | "hello" |
Number | Number | 42, 3.14 |
Boolean | Boolean | true, false |
Date | Date | new Date() |
Buffer | Buffer | Binary data |
ObjectId | ObjectId | References to other documents |
Array | Array | ["a", "b"] |
Map | Map | Key-value pairs |
Schema.Types.Mixed | Object | Any 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 together | Data is accessed independently |
| One-to-few relationship (< 100) | One-to-many or many-to-many |
| Data doesn't change often | Data changes frequently |
| Example: address in user | Example: 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.jsError 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_HEREExercises
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/likeandDELETE /api/posts/:id/likeendpoints - 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.
Exercise 3: Full-Text Search
Implement search across posts:
- Create a text index on
titleandcontent - 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.