Mongoose middleware — also called hooks — lets you run logic automatically before or after document operations: before saving a password, after creating a user, before finding to add a soft-delete filter, after deleting to clean up related documents. Without hooks this logic would have to be duplicated in every controller that touches the affected operation. With hooks it lives in the model where it belongs — running automatically, invisibly, every time the operation occurs. In this lesson you will build every hook the MERN Blog needs and understand the differences between document, query, and aggregate middleware.
Mongoose Middleware Types
| Type | Triggers on | ‘this’ refers to |
|---|---|---|
| Document | save, validate, remove, deleteOne, updateOne (on doc) | The document instance |
| Query | find, findOne, findOneAndUpdate, deleteOne, updateOne (on Model) | The Query object |
| Aggregate | aggregate() | The Aggregation object |
| Model | insertMany | The Model |
deleteOne and updateOne middleware types — document middleware (triggered by calling doc.deleteOne() on a document instance) and query middleware (triggered by calling Model.deleteOne(filter) on the model). They use different this contexts and are registered differently. When you delete a document in your MERN API using await post.deleteOne(), document middleware runs. When you use await Post.deleteOne({ _id: id }), query middleware runs.pre('save') hooks for operations that should run every time a document is created or updated — like slug generation from the title, or incrementing a version counter. Use pre(/^find/) (regex form) to match ALL find-related operations with a single hook — find, findOne, findById, findOneAndUpdate, and findByIdAndUpdate all trigger it.findByIdAndUpdate() and updateOne() do NOT trigger pre('save') hooks — they bypass the document entirely and update MongoDB directly. If you have critical logic in a save hook (like hashing a password), and a user updates their password via findByIdAndUpdate, the hook will not run and the password will be stored in plaintext. Always use doc.save() for operations that need hooks, or explicitly run the hook logic in your controller.pre(‘save’) — Before Creating or Updating
// ── Password hashing ──────────────────────────────────────────────────────────
userSchema.pre('save', async function (next) {
// Only hash if password field was modified (or is new)
if (!this.isModified('password')) return next();
try {
this.password = await bcrypt.hash(this.password, 12);
next();
} catch (err) {
next(err); // pass error to Mongoose error handling
}
});
// ── Slug generation from title ────────────────────────────────────────────────
postSchema.pre('save', function (next) {
if (!this.isModified('title')) return next(); // skip if title unchanged
this.slug = this.title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
next();
});
// ── Auto-set publishedAt when published changes to true ───────────────────────
postSchema.pre('save', function (next) {
if (this.isModified('published') && this.published && !this.publishedAt) {
this.publishedAt = new Date();
}
next();
});
pre(‘find’) — Query Middleware
// ── Soft delete filter — exclude deleted documents from ALL find queries ───────
// Uses regex /^find/ to match: find, findOne, findById, findOneAndUpdate, etc.
postSchema.pre(/^find/, function (next) {
// 'this' is the Mongoose Query object
if (!this.getOptions().includeDeleted) {
this.where({ deletedAt: null }); // add condition to the query
}
next();
});
// Usage:
const posts = await Post.find({ published: true });
// → automatically adds { deletedAt: null } to the filter
// To include soft-deleted documents (admin restore endpoint):
const allPosts = await Post.find({}).setOptions({ includeDeleted: true });
// ── Auto-populate author on all find queries ──────────────────────────────────
postSchema.pre(/^find/, function (next) {
this.populate({ path: 'author', select: 'name avatar' });
next();
});
// Now every Post.find() automatically includes author data — no need to call .populate()
post(‘save’) — After Saving
// ── Send welcome email after user registration ─────────────────────────────────
userSchema.post('save', async function (doc, next) {
// 'doc' is the saved document, 'this' is also the document
if (doc.wasNew) { // custom flag set in pre('save')
try {
await sendWelcomeEmail(doc.email, doc.name);
} catch (err) {
console.error('Welcome email failed:', err.message);
// Do NOT block the save — email failure is non-critical
}
}
next();
});
// ── Invalidate cache after post update ────────────────────────────────────────
postSchema.post('save', async function (doc) {
await cache.del(`post:${doc._id}`);
await cache.del(`post:slug:${doc.slug}`);
// Fire-and-forget — don't await or block the save
});
post(‘findOneAndDelete’) — Cascade Delete
// ── Delete related comments when a post is deleted ────────────────────────────
postSchema.post('findOneAndDelete', async function (doc) {
if (doc) {
const Comment = mongoose.model('Comment');
await Comment.deleteMany({ post: doc._id });
console.log(`Cascade deleted comments for post ${doc._id}`);
}
});
// This runs automatically when you use:
await Post.findByIdAndDelete(postId);
// or:
await Post.findOneAndDelete({ _id: postId });
// ── Delete user's posts when user is deleted ──────────────────────────────────
userSchema.post('findOneAndDelete', async function (doc) {
if (doc) {
const Post = mongoose.model('Post');
await Post.deleteMany({ author: doc._id });
}
});
pre(‘validate’) — Before Validation
// ── Auto-generate excerpt from body if not provided ───────────────────────────
postSchema.pre('validate', function (next) {
if (!this.excerpt && this.body) {
// Generate excerpt: first 200 characters, trimmed at word boundary
this.excerpt = this.body
.replace(/<[^>]+>/g, '') // strip HTML tags
.slice(0, 200)
.replace(/\s+\S*$/, '...'); // trim at word boundary
}
next();
});
Common Mistakes
Mistake 1 — Using findByIdAndUpdate for password changes (bypasses pre save hook)
❌ Wrong — hashing hook does not run with findByIdAndUpdate:
await User.findByIdAndUpdate(userId, { password: req.body.newPassword });
// pre('save') hook does NOT run — password stored as plaintext!
✅ Correct — fetch the user and use save() so the hashing hook runs:
const user = await User.findById(userId);
user.password = req.body.newPassword;
await user.save(); // pre('save') hash hook runs ✓
Mistake 2 — Blocking the pipeline with an async post hook that throws
❌ Wrong — post hook error blocks the response even though save succeeded:
userSchema.post('save', async function (doc, next) {
await sendWelcomeEmail(doc.email); // throws if email service is down
next(); // never reached → request hangs
});
✅ Correct — wrap non-critical post-save operations in try/catch:
userSchema.post('save', async function (doc, next) {
try {
await sendWelcomeEmail(doc.email);
} catch (err) {
console.error('Email failed:', err.message); // log but do not block
}
next(); // always call next() ✓
});
Mistake 3 — Forgetting that pre(/^find/) also intercepts update queries
❌ Wrong — auto-populate hook runs on findOneAndUpdate and adds unexpected data:
postSchema.pre(/^find/, function (next) {
this.populate('author'); // runs on ALL find-type queries including findOneAndUpdate
// findByIdAndUpdate now returns a populated document — may be unexpected
next();
});
✅ Correct — check query operation type if needed:
postSchema.pre(/^find/, function (next) {
if (!this.op.startsWith('findOneAndUpdate')) {
this.populate({ path: 'author', select: 'name avatar' });
}
next();
});
Quick Reference
| Hook | Triggers On | ‘this’ is |
|---|---|---|
pre('save') |
create(), save() | Document |
post('save') |
After create(), save() | Document |
pre('validate') |
Before validation | Document |
pre(/^find/) |
All find operations | Query |
post('findOneAndDelete') |
After findByIdAndDelete | Query (doc passed as arg) |
pre('deleteOne') |
doc.deleteOne() | Document |