MongoDB & Mongoose: CRUD Operations in Node.js

MongoDB & Mongoose: CRUD Operations in Node.js
MongoDB is the database of choice for a large share of Node.js applications. Its document model maps naturally to JavaScript objects, and its horizontal scaling story handles internet-scale traffic. But MongoDB alone gives you no structure. Mongoose gives you that structure while keeping MongoDB's flexibility.
In this module you will connect to MongoDB, define schemas and models, run every type of CRUD operation, handle validation errors, and wire the data layer into the Express feature structure from Module 13.
This is Module 14 of the Node.js Full‑Stack Developer course.
Installing Mongoose
npm install mongooseMongoose requires no separate MongoDB driver installation — it bundles the official driver internally.
Connecting to MongoDB
Create a dedicated config module for the database connection:
// config/db.js
import mongoose from 'mongoose';
export async function connectDB() {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error('MONGODB_URI environment variable is not set');
}
mongoose.connection.on('connected', () => {
console.log('MongoDB connected');
});
mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err);
});
mongoose.connection.on('disconnected', () => {
console.warn('MongoDB disconnected');
});
await mongoose.connect(uri, {
serverSelectionTimeoutMS: 5000, // fail fast if MongoDB is unreachable
});
}Call connectDB() before starting the HTTP server:
// server.js
import app from './app.js';
import { connectDB } from './config/db.js';
const PORT = process.env.PORT || 3000;
async function start() {
await connectDB();
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
}
start().catch(err => {
console.error('Failed to start:', err);
process.exit(1);
});For local development, your .env file:
MONGODB_URI=mongodb://localhost:27017/myappFor production, use a connection string from MongoDB Atlas.
Defining a Schema
A Mongoose schema defines the shape of documents in a collection. Every field has a type and optional constraints:
// features/users/users.model.js
import mongoose from 'mongoose';
import bcrypt from 'bcrypt';
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [100, 'Name must be at most 100 characters'],
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Invalid email format'],
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: 8,
select: false, // excluded from queries by default
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user',
},
isActive: {
type: Boolean,
default: true,
},
lastLogin: {
type: Date,
},
},
{
timestamps: true, // adds createdAt and updatedAt automatically
toJSON: {
virtuals: true,
transform(doc, ret) {
delete ret.password; // never serialize password
delete ret.__v;
return ret;
},
},
}
);
// ── Virtuals ─────────────────────────────────────────────────────────────────
userSchema.virtual('isAdmin').get(function () {
return this.role === 'admin';
});
// ── Pre-save middleware ───────────────────────────────────────────────────────
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
// ── Instance methods ──────────────────────────────────────────────────────────
userSchema.methods.comparePassword = async function (candidate) {
return bcrypt.compare(candidate, this.password);
};
// ── Static methods ────────────────────────────────────────────────────────────
userSchema.statics.findByEmail = function (email) {
return this.findOne({ email: email.toLowerCase() }).select('+password');
};
export const User = mongoose.model('User', userSchema);Schema Types
| Type | Example |
|---|---|
String | Name, email, role |
Number | Price, age, quantity |
Boolean | isActive, isVerified |
Date | createdAt, expiresAt |
ObjectId | Foreign keys (refs) |
Array | Tags, permissions |
Mixed | Arbitrary objects (avoid) |
Map | Key-value with dynamic keys |
Buffer | Binary data |
CRUD Operations
Create
// Method 1: Model.create() — single document
const user = await User.create({
name: 'Alice',
email: 'alice@example.com',
password: 'secret123',
});
// Method 2: new Model() + save() — gives you pre-save hook control
const user = new User({ name: 'Alice', email: 'alice@example.com', password: 'secret123' });
await user.save();
// Method 3: insertMany() — efficient bulk insert
const users = await User.insertMany([
{ name: 'Bob', email: 'bob@example.com', password: 'pw1' },
{ name: 'Carol', email: 'carol@example.com', password: 'pw2' },
]);Read
// Find all
const users = await User.find();
// Find with filter
const activeUsers = await User.find({ isActive: true });
// Find one
const user = await User.findOne({ email: 'alice@example.com' });
// Find by primary key
const user = await User.findById('64abc123def456...');
// Projection — select specific fields
const users = await User.find({}, { name: 1, email: 1 }); // include
const users = await User.find({}, { password: 0 }); // exclude
// Query builder (chainable)
const users = await User
.find({ isActive: true })
.select('name email role')
.sort({ createdAt: -1 })
.skip(20)
.limit(10)
.lean(); // plain objects, faster for reads
// Count
const total = await User.countDocuments({ isActive: true });Query Operators
// Comparison
await User.find({ age: { $gt: 18, $lt: 65 } });
await User.find({ role: { $in: ['admin', 'moderator'] } });
await User.find({ role: { $nin: ['banned'] } });
await User.find({ deletedAt: { $exists: false } });
// Logical
await User.find({ $or: [{ role: 'admin' }, { isActive: false }] });
await User.find({ $and: [{ isActive: true }, { role: 'user' }] });
// String
await User.find({ name: /^alice/i }); // regex
await User.find({ name: { $regex: 'ali', $options: 'i' } });
// Array
await User.find({ tags: 'nodejs' }); // contains element
await User.find({ tags: { $all: ['node', 'express'] } }); // contains all
await User.find({ tags: { $size: 3 } }); // array lengthUpdate
// findByIdAndUpdate — returns updated document
const user = await User.findByIdAndUpdate(
id,
{ $set: { name: 'Alice Updated', isActive: false } },
{ new: true, runValidators: true } // new: return updated doc; runValidators: run schema validators
);
// findOneAndUpdate — find by any field
const user = await User.findOneAndUpdate(
{ email: 'alice@example.com' },
{ $set: { lastLogin: new Date() } },
{ new: true }
);
// updateMany — update multiple documents
const result = await User.updateMany(
{ isActive: false, updatedAt: { $lt: new Date('2025-01-01') } },
{ $set: { archived: true } }
);
console.log(result.modifiedCount); // how many were updated
// Atomic operators
await User.findByIdAndUpdate(id, {
$set: { name: 'New Name' }, // set fields
$unset: { tempToken: '' }, // remove field
$inc: { loginCount: 1 }, // increment
$push: { tags: 'nodejs' }, // append to array
$pull: { tags: 'python' }, // remove from array
$addToSet: { permissions: 'write' }, // add to array if not present
});Delete
// findByIdAndDelete — delete and return the deleted document
const deleted = await User.findByIdAndDelete(id);
if (!deleted) throw new AppError('User not found', 404);
// deleteMany — delete multiple
const result = await User.deleteMany({ isActive: false, archived: true });
console.log(result.deletedCount);
// Soft delete (recommended for most production apps)
const user = await User.findByIdAndUpdate(
id,
{ deletedAt: new Date() },
{ new: true }
);
// Then filter all queries: User.find({ deletedAt: null })Validation Errors
When a Mongoose validation fails, it throws a ValidationError with a errors map. Catch it in the global error handler:
// middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
// Mongoose validation error
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(e => ({
field: e.path,
message: e.message,
}));
return res.status(422).json({ status: 'error', errors });
}
// MongoDB duplicate key (unique constraint violation)
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({
status: 'error',
message: `${field} is already taken`,
});
}
// Mongoose CastError (invalid ObjectId)
if (err.name === 'CastError') {
return res.status(400).json({
status: 'error',
message: `Invalid ${err.path}: ${err.value}`,
});
}
const status = err.statusCode || 500;
res.status(status).json({ status: 'error', message: err.message });
}ObjectId Validation
Always validate req.params.id before passing it to Mongoose:
// middleware/validateObjectId.js
import mongoose from 'mongoose';
import { AppError } from '../lib/errors.js';
export function validateObjectId(req, res, next) {
if (!mongoose.isValidObjectId(req.params.id)) {
return next(new AppError(`Invalid id: ${req.params.id}`, 400));
}
next();
}// users.router.js
router.get('/:id', validateObjectId, requireAuth, controller.getById);
router.put('/:id', validateObjectId, requireAuth, validate(updateUserSchema), controller.update);
router.delete('/:id', validateObjectId, requireAuth, requireRole('admin'), controller.remove);Pagination
// features/users/users.service.js
export async function listUsers({ page = 1, limit = 20, sort = '-createdAt' } = {}) {
const pageNum = Math.max(1, parseInt(page));
const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
const skip = (pageNum - 1) * limitNum;
const [users, total] = await Promise.all([
User.find({ deletedAt: null })
.select('-password -__v')
.sort(sort)
.skip(skip)
.limit(limitNum)
.lean(),
User.countDocuments({ deletedAt: null }),
]);
return {
data: users,
meta: {
total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
hasNextPage: pageNum < Math.ceil(total / limitNum),
hasPrevPage: pageNum > 1,
},
};
}Populating References
When one document references another by ObjectId, .populate() fetches the referenced document:
// features/posts/posts.model.js
const postSchema = new mongoose.Schema({
title: { type: String, required: true },
body: { type: String, required: true },
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User', // reference the 'User' model
required: true,
},
tags: [String],
}, { timestamps: true });
export const Post = mongoose.model('Post', postSchema);// Populate the author field
const posts = await Post
.find()
.populate('author', 'name email') // only fetch name and email
.lean();
// posts[0].author is now { _id, name, email } instead of an ObjectId
// Nested populate
const posts = await Post
.find()
.populate({
path: 'author',
select: 'name email',
populate: {
path: 'company',
select: 'name',
},
})
.lean();Use populate judiciously — each populated field is an additional database query. For performance-critical list endpoints, consider storing denormalized data or using MongoDB's $lookup aggregation instead.
Aggregation Pipeline
For complex read operations — grouping, joining, computing stats — use MongoDB's aggregation pipeline:
// Get user count by role
const stats = await User.aggregate([
{ $match: { isActive: true } },
{ $group: { _id: '$role', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
]);
// [{ _id: 'user', count: 124 }, { _id: 'admin', count: 3 }]
// Revenue by month
const revenue = await Order.aggregate([
{ $match: { status: 'completed', createdAt: { $gte: startOfYear } } },
{
$group: {
_id: { $month: '$createdAt' },
total: { $sum: '$amount' },
count: { $sum: 1 },
},
},
{ $sort: { '_id': 1 } },
]);Indexes
Without indexes, MongoDB scans every document for every query. Add indexes for any field you filter or sort on frequently:
const userSchema = new mongoose.Schema({
email: { type: String, unique: true }, // unique: true creates an index automatically
role: String,
isActive: Boolean,
createdAt: Date,
});
// Compound index for common query pattern
userSchema.index({ isActive: 1, createdAt: -1 });
// Text index for full-text search
userSchema.index({ name: 'text', email: 'text' });Check which indexes exist:
# In MongoDB shell
db.users.getIndexes()Environment Variables for MongoDB
# .env
MONGODB_URI=mongodb+srv://username:password@cluster0.abc.mongodb.net/myapp?retryWrites=true&w=majorityFor local development with Docker:
# docker-compose.yml
services:
mongo:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
volumes:
mongo_data:MONGODB_URI=mongodb://localhost:27017/myappNode.js Full‑Stack Course — Module 14 of 32
You can now connect an Express API to MongoDB and run any CRUD operation with Mongoose. Continue to Module 15 to learn PostgreSQL with Prisma ORM.
Summary
MongoDB and Mongoose together form a productive data layer for Node.js:
- Connect with
mongoose.connect(uri)before starting the HTTP server — never inside route handlers - Schema types and validators enforce data integrity at the application level
{ timestamps: true }addscreatedAtandupdatedAtautomaticallyselect: falsehides sensitive fields (likepassword) from all queries by default- Use
.lean()on read-only list queries for a significant performance improvement - Handle
ValidationError, code11000(duplicate), andCastError(bad ObjectId) in the global error handler - Always check
mongoose.isValidObjectId(id)before querying to avoid uncaught CastErrors - Use
.populate()to join documents; use aggregation for complex reporting queries
Continue to Module 15: PostgreSQL & Prisma ORM →
