Express.js MongoDB & Mongoose

Schemas, models, CRUD operations, validation, and population with MongoDB

MongoDB Mongoose Schemas CRUD

Table of Contents

1. Introduction to MongoDB and Mongoose

1.1 What is MongoDB?

MongoDB is a NoSQL document database that stores data in flexible, JSON-like documents instead of traditional tables and rows. This makes it naturally fit with JavaScript applications.

Key characteristics:

  • Document-oriented: Data stored as BSON (Binary JSON) documents
  • Schema-flexible: Documents in the same collection can have different fields
  • Scalable: Built for horizontal scaling across many servers
  • High performance: Embedded documents reduce the need for joins

Comparison: SQL vs MongoDB

SQL DatabaseMongoDB
TableCollection
RowDocument
ColumnField
Primary Key_id field
JOIN$lookup or populate()
Fixed SchemaDynamic Schema

Example MongoDB Document:

JSON{ "_id": ObjectId("507f1f77bcf86cd799439011"), "title": "1984", "author": "George Orwell", "year": 1949, "genres": ["Dystopian", "Political Fiction"], "available": true }

1.2 What is Mongoose?

Mongoose is an ODM (Object Document Mapper) for MongoDB and Node.js. It provides a straightforward, schema-based solution to model your application data.

What Mongoose gives you:

  • Schema validation: Define what fields your documents should have
  • Type casting: Automatically converts values to correct types
  • Query building: Chainable methods for complex queries
  • Middleware: Pre/post hooks for save, validate, remove operations
  • Population: Virtual joins between collections
JavaScript// Without Mongoose - raw MongoDB driver db.collection('users').findOne({ name: 'John' }, (err, user) => { // Manual handling }); // With Mongoose const user = await User.findOne({ name: 'John' });

1.3 Why Use Mongoose with Express?

When building REST APIs with Express, Mongoose provides a structured way to interact with MongoDB:

  • Reduces boilerplate: Built-in methods for CRUD operations
  • Data consistency: Enforces schema rules before saving
  • Developer experience: Intuitive API with promises/async-await
  • Validation: Automatic validation on save
  • Relationships: Easy document referencing and population

2. Setting Up MongoDB

2.1 Local MongoDB Installation

Windows:

  1. Download MongoDB Community Edition from mongodb.com
  2. Run the installer (choose "Complete" setup)
  3. Add MongoDB to PATH
  4. Create data directory: C:\data\db

macOS (using Homebrew):

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

Linux (Ubuntu):

Bashwget -qO - https://www.mongodb.org/static/pgp/server-6.0.asc | sudo apt-key add - echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/6.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-6.0.list sudo apt-get update sudo apt-get install -y mongodb-org sudo systemctl start mongod

2.2 MongoDB Atlas (Cloud Database)

For development and production, MongoDB Atlas is recommended.

Setup steps:

  1. Go to mongodb.com/atlas
  2. Sign up for a free account
  3. Create a new cluster (Shared/Free tier is sufficient for learning)
  4. Choose cloud provider (AWS, GCP, or Azure) and region
  5. Create a database user (username/password)
  6. Add your IP address to the network access list
  7. Click "Connect" → "Connect your application"
  8. Copy the connection string

Connection string format:

Textmongodb+srv://<username>:<password>@<cluster-url>/<database-name>?retryWrites=true&w=majority

2.3 Connection String Explained

Textmongodb+srv://john:pass123@cluster0.abcde.mongodb.net/myDatabase?retryWrites=true&w=majority │ │ │ │ │ Protocol User Password Cluster URL Database Name
ComponentDescription
mongodb+srv://Protocol for SRV connection
username:passwordAuthentication credentials
cluster-urlYour Atlas cluster address
database-nameOptional default database
?retryWrites=trueQuery parameters

3. Connecting Mongoose to Express

3.1 Installing Dependencies

Initialize your project and install required packages:

Bashmkdir my-express-app cd my-express-app npm init -y npm install express mongoose dotenv npm install -D nodemon

Package.json scripts:

JSON{ "scripts": { "start": "node server.js", "dev": "nodemon server.js" } }

3.2 Basic Connection Setup

server.js — Entry point:

JavaScript — server.jsconst express = require('express'); const mongoose = require('mongoose'); const dotenv = require('dotenv'); dotenv.config(); const app = express(); app.use(express.json()); // Database connection const MONGODB_URI = process.env.MONGODB_URI; mongoose.connect(MONGODB_URI) .then(() => { console.log('Connected to MongoDB'); app.listen(3000, () => { console.log('Server running on port 3000'); }); }) .catch((error) => { console.error('MongoDB connection error:', error); process.exit(1); });

.env file:

Text — .envMONGODB_URI=mongodb+srv://your-username:your-password@cluster0.abcde.mongodb.net/myDatabase?retryWrites=true&w=majority PORT=3000

3.3 Connection Event Handlers

For better monitoring, add connection event listeners:

JavaScriptconst mongoose = require('mongoose'); // Connection events mongoose.connection.on('connected', () => { console.log('Mongoose connected to MongoDB'); }); mongoose.connection.on('error', (err) => { console.error('Mongoose connection error:', err); }); mongoose.connection.on('disconnected', () => { console.log('Mongoose disconnected'); }); // Graceful shutdown process.on('SIGINT', async () => { await mongoose.connection.close(); console.log('Mongoose connection closed due to app termination'); process.exit(0); });

3.4 Environment Variables Configuration

config/db.js — Modular connection setup:

JavaScript — config/db.jsconst mongoose = require('mongoose'); const connectDB = async () => { try { const conn = await mongoose.connect(process.env.MONGODB_URI, { // These options are now defaults in Mongoose 6+, but shown for reference useNewUrlParser: true, useUnifiedTopology: true, }); console.log(`MongoDB Connected: ${conn.connection.host}`); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); } }; module.exports = connectDB;

server.js using modular connection:

JavaScript — server.jsconst express = require('express'); const dotenv = require('dotenv'); const connectDB = require('./config/db'); dotenv.config(); connectDB(); const app = express(); // ... rest of the app

4. Understanding Schemas

4.1 What is a Schema?

A Schema defines the structure of documents within a MongoDB collection. It's a blueprint that tells Mongoose what fields a document should have, their types, and validation rules.

JavaScriptconst mongoose = require('mongoose'); const Schema = mongoose.Schema; const userSchema = new Schema({ name: String, email: String, age: Number });

Key points about schemas:

  • Schemas are not saved to the database
  • They're compiled into Models, which interact with collections
  • Fields not defined in the schema will NOT be saved (by default)

4.2 Schema Types (String, Number, Date, etc.)

Mongoose supports various SchemaTypes:

SchemaTypeDescriptionExample
StringUTF-8 string"John Doe"
NumberInteger or float42, 3.14
DateDate objectnew Date()
Booleantrue/falsetrue
ObjectIdMongoDB document IDnew mongoose.Types.ObjectId()
ArrayArray of values[1, 2, 3]
MixedAny type (flexible){ any: 'value' }
MapKey-value pairsnew Map()

Complete example with multiple types:

JavaScriptconst productSchema = new Schema({ // String with validation name: { type: String, required: true }, // Number with min/max price: { type: Number, min: 0, max: 10000 }, // Boolean with default inStock: { type: Boolean, default: true }, // Date with default createdAt: { type: Date, default: Date.now }, // Array of strings tags: [String], // Mixed type (flexible) metadata: Schema.Types.Mixed, // ObjectId reference to another model category: { type: Schema.Types.ObjectId, ref: 'Category' } });

4.3 Field Options (required, default, unique)

Each field can have options that control its behavior:

OptionDescriptionExample
requiredField must be presentrequired: true
defaultDefault value if not provideddefault: 'Anonymous'
uniqueCreate unique indexunique: true
lowercaseConvert string to lowercaselowercase: true
uppercaseConvert string to uppercaseuppercase: true
trimRemove whitespacetrim: true
min / maxNumber rangemin: 0, max: 100
minlength / maxlengthString lengthminlength: 3
enumAllowed valuesenum: ['admin', 'user']
matchRegular expressionmatch: /^[a-z]+$/
get / setCustom getters/settersget: v => v.toUpperCase()

Example with comprehensive options:

JavaScriptconst userSchema = new Schema({ username: { type: String, required: [true, 'Username is required'], unique: true, trim: true, minlength: [3, 'Username must be at least 3 characters'], maxlength: [20, 'Username cannot exceed 20 characters'] }, email: { type: String, required: true, unique: true, lowercase: true, trim: true, match: [/^\\S+@\\S+\\.\\S+$/, 'Please enter a valid email'] }, age: { type: Number, min: [13, 'Age must be at least 13'], max: [120, 'Age cannot exceed 120'] }, role: { type: String, enum: { values: ['user', 'moderator', 'admin'], message: '{VALUE} is not a valid role' }, default: 'user' }, isActive: { type: Boolean, default: true }, lastLogin: { type: Date, default: null } });

4.4 Nested Objects and Arrays

Schemas can have nested objects and arrays for complex data structures:

JavaScript// Subdocument schema (nested) const addressSchema = new Schema({ street: { type: String, required: true }, city: { type: String, required: true }, zipCode: { type: String, required: true }, country: { type: String, default: 'USA' } }); // Main schema with nested objects const userSchema = new Schema({ name: String, address: addressSchema, // Single nested document previousAddresses: [addressSchema], // Array of subdocuments preferences: { notifications: { type: Boolean, default: true }, theme: { type: String, enum: ['light', 'dark'], default: 'light' } } });

Working with nested documents:

JavaScript// Creating a user with nested data const user = new User({ name: 'Alice', address: { street: '123 Main St', city: 'Boston', zipCode: '02101' }, previousAddresses: [ { street: '456 Oak Ave', city: 'Cambridge', zipCode: '02138' } ], preferences: { notifications: false } }); // Accessing nested fields console.log(user.address.city); // 'Boston' console.log(user.preferences.theme); // 'light' (default)

4.5 Virtual Properties

Virtual properties are not stored in the database but computed on the fly.

JavaScriptconst userSchema = new Schema({ firstName: { type: String, required: true }, lastName: { type: String, required: true } }); // Virtual property - fullName is not persisted userSchema.virtual('fullName').get(function() { return `${this.firstName} ${this.lastName}`; }); // Virtual with setter userSchema.virtual('fullName').set(function(fullName) { const parts = fullName.split(' '); this.firstName = parts[0]; this.lastName = parts[1]; }); const User = mongoose.model('User', userSchema); const user = new User({ firstName: 'John', lastName: 'Doe' }); console.log(user.fullName); // 'John Doe'

Common use cases for virtuals:

  • Computed fields (full name, age from birthdate)
  • URLs for resources (e.g., /api/users/:id)
  • Formatting dates or numbers
  • Aggregating data from multiple fields

5. Creating Models

5.1 Defining a Model

A Model is a compiled version of a Schema that provides an interface to interact with a MongoDB collection.

JavaScriptconst mongoose = require('mongoose'); // 1. Define the schema const bookSchema = new mongoose.Schema({ title: { type: String, required: true }, author: { type: String, required: true }, year: Number, isbn: String }); // 2. Compile schema into a model const Book = mongoose.model('Book', bookSchema); // 3. Use the model to interact with 'books' collection module.exports = Book;

Important naming conventions:

  • Model names are typically singular and PascalCase (Book, User, Product)
  • Mongoose automatically creates a plural, lowercase collection name (books, users, products)

5.2 The Model vs Document Distinction

Understanding the distinction is crucial:

ModelDocument
Represents the entire collectionRepresents a single record
Created with mongoose.model()Created with new Model() or Model.create()
Used for queries like Book.find()Used for instance methods like doc.save()
One per collectionMany per collection
JavaScript// Model - interacts with the collection const Book = mongoose.model('Book', bookSchema); // Document - a single record const myBook = new Book({ title: 'The Hobbit', author: 'J.R.R. Tolkien' }); // Model methods operate on collection await Book.find({ author: 'Tolkien' }); // Document methods operate on one record await myBook.save();

5.3 Registering Models

Option 1: Direct export (Single file)

JavaScript — models/User.jsconst mongoose = require('mongoose'); const userSchema = new mongoose.Schema({ name: String, email: String }); module.exports = mongoose.model('User', userSchema);

Option 2: Index file for multiple models

JavaScript — models/index.jsconst mongoose = require('mongoose'); const User = require('./User'); const Post = require('./Post'); const Comment = require('./Comment'); module.exports = { User, Post, Comment }; // Usage in app.js const { User, Post } = require('./models');

Option 3: Using mongoose.models to prevent recompilation

JavaScriptconst User = mongoose.models.User || mongoose.model('User', userSchema);

6. Data Validation

Mongoose provides both built-in and custom validators to ensure data integrity before saving to the database.

6.1 Built-in Validators (required, min, max, enum)

Mongoose includes several built-in validators:

JavaScriptconst productSchema = new mongoose.Schema({ // Required validator name: { type: String, required: [true, 'Product name is required'] }, // String validators category: { type: String, enum: { values: ['Electronics', 'Clothing', 'Books'], message: '{VALUE} is not a valid category' } }, code: { type: String, match: [/^[A-Z]{3}-\\d{4}$/, 'Invalid code format (e.g., ABC-1234)'], minlength: [5, 'Code must be at least 5 characters'], maxlength: [10, 'Code cannot exceed 10 characters'] }, // Number validators price: { type: Number, min: [0, 'Price cannot be negative'], max: [10000, 'Price cannot exceed $10,000'] }, quantity: { type: Number, min: 0, validate: { validator: Number.isInteger, message: 'Quantity must be an integer' } } });

6.2 Custom Validators

When built-in validators aren't enough, create custom validation logic:

JavaScriptconst userSchema = new mongoose.Schema({ email: { type: String, required: true, // Custom async validator to check uniqueness validate: { validator: async function(email) { const existingUser = await this.constructor.findOne({ email }); return !existingUser || existingUser._id.equals(this._id); }, message: 'Email already exists' } }, phone: { type: String, // Sync custom validator with regex validate: { validator: function(v) { return /^\\d{3}-\\d{3}-\\d{4}$/.test(v); }, message: props => `${props.value} is not a valid phone number! Use format: XXX-XXX-XXXX` } }, // Alternative: simple array syntax for custom validator website: { type: String, validate: { validator: (url) => url.startsWith('https://'), message: 'Website must use HTTPS' } } });

6.3 Validation Error Handling

When validation fails, Mongoose throws a ValidationError:

JavaScripttry { const user = new User({ email: 'invalid-email', age: -5 }); await user.save(); } catch (error) { if (error.name === 'ValidationError') { // Loop through validation errors Object.keys(error.errors).forEach(field => { console.log(`${field}: ${error.errors[field].message}`); }); // Send structured response return res.status(400).json({ status: 'error', message: 'Validation failed', errors: Object.values(error.errors).map(err => ({ field: err.path, message: err.message, value: err.value })) }); } }

Manual validation:

JavaScript// Validate without saving const user = new User({ name: 'John' }); const validationError = user.validateSync(); if (validationError) { // Handle validation error } // Or async validation await user.validate();

Important note about unique: The unique option is not a validator. It creates a MongoDB unique index. Duplicate errors appear as MongoServerError (code 11000), not validation errors.

7. CRUD Operations with Mongoose

7.1 CREATE: Saving Documents

Method 1: Create instance + save()

JavaScriptconst user = new User({ name: 'John Doe', email: 'john@example.com', age: 30 }); const savedUser = await user.save(); console.log('User created:', savedUser);

Method 2: Model.create()

JavaScriptconst user = await User.create({ name: 'Jane Smith', email: 'jane@example.com', age: 25 });

Method 3: insertMany() for multiple documents

JavaScriptconst users = await User.insertMany([ { name: 'User 1', email: 'user1@example.com' }, { name: 'User 2', email: 'user2@example.com' }, { name: 'User 3', email: 'user3@example.com' } ]);

7.2 READ: Finding Documents (find, findById, findOne)

JavaScript// Find all documents const allUsers = await User.find(); // Find with conditions const activeUsers = await User.find({ isActive: true }); // Find one document const user = await User.findOne({ email: 'john@example.com' }); // Find by ID const user = await User.findById('507f1f77bcf86cd799439011'); // Select specific fields const users = await User.find() .select('name email age') .where('age').gte(18).lte(65) .sort({ name: 1 }) .limit(10) .skip(20); // Count documents const count = await User.countDocuments({ isActive: true });

Query operators reference:

OperatorMeaningExample
$eqEqual to{ age: { $eq: 30 } }
$neNot equal to{ age: { $ne: 30 } }
$gtGreater than{ age: { $gt: 18 } }
$gteGreater than or equal{ age: { $gte: 18 } }
$ltLess than{ age: { $lt: 65 } }
$lteLess than or equal{ age: { $lte: 65 } }
$inIn array{ role: { $in: ['admin', 'moderator'] } }
$ninNot in array{ role: { $nin: ['banned'] } }
$orLogical OR{ $or: [{ age: 25 }, { age: 30 }] }
$andLogical AND{ $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] }

7.3 UPDATE: Updating Documents (updateOne, findByIdAndUpdate)

JavaScript// Update one document const result = await User.updateOne( { email: 'john@example.com' }, { $set: { age: 31, updatedAt: new Date() } } ); // Update multiple documents const result = await User.updateMany( { isActive: false }, { $set: { isActive: true } } ); // Find and update (returns updated document) const user = await User.findByIdAndUpdate( userId, { name: 'New Name' }, { new: true, runValidators: true } ); // Update using save() - good for complex updates const user = await User.findById(userId); user.name = 'Updated Name'; user.age = 32; await user.save();

Update operators:

  • $set — Set field values
  • $inc — Increment a number
  • $unset — Remove a field
  • $push — Add to array
  • $pull — Remove from array
  • $addToSet — Add to array if not exists

7.4 DELETE: Removing Documents (deleteOne, findByIdAndDelete)

JavaScript// Delete one document const result = await User.deleteOne({ email: 'old@example.com' }); // Delete many documents const result = await User.deleteMany({ isActive: false }); // Find and delete const deletedUser = await User.findByIdAndDelete(userId); // Delete all documents (careful!) await User.deleteMany({});

Soft Delete pattern (don't actually remove):

JavaScriptconst userSchema = new Schema({ name: String, isDeleted: { type: Boolean, default: false }, deletedAt: Date }); // Soft delete method userSchema.methods.softDelete = function() { this.isDeleted = true; this.deletedAt = new Date(); return this.save(); }; // Exclude soft-deleted documents by default userSchema.pre('find', function() { this.where({ isDeleted: false }); });

8. Model Relationships (Population)

8.1 Referencing Other Models

In MongoDB, relationships are created by storing a reference (ObjectId) to another document.

JavaScript// Author model const authorSchema = new Schema({ name: { type: String, required: true }, bio: String }); const Author = mongoose.model('Author', authorSchema); // Book model with reference to Author const bookSchema = new Schema({ title: { type: String, required: true }, author: { type: Schema.Types.ObjectId, ref: 'Author' }, // Reference! pages: Number }); const Book = mongoose.model('Book', bookSchema); // Usage: const author = await Author.create({ name: 'George Orwell' }); const book = await Book.create({ title: '1984', author: author._id // Store the reference });

8.2 Using populate() for Joins

The populate() method replaces the ObjectId reference with the actual document from another collection.

JavaScript// Basic populate const book = await Book.findById(bookId).populate('author'); console.log(book.author.name); // 'George Orwell' - the actual author document // Populate with field selection const book = await Book.findById(bookId).populate('author', 'name bio'); // Nested populate const postSchema = new Schema({ title: String, author: { type: Schema.Types.ObjectId, ref: 'User' }, comments: [{ type: Schema.Types.ObjectId, ref: 'Comment' }] }); const post = await Post.findById(postId) .populate('author', 'name') .populate({ path: 'comments', populate: { path: 'user', select: 'name' } }); // Multiple populations const book = await Book.findById(bookId) .populate('author') .populate('publisher') .populate('genres');

8.3 One-to-Many and Many-to-Many Relationships

One-to-Many: Author to Books

JavaScriptconst authorSchema = new Schema({ name: String, books: [{ type: Schema.Types.ObjectId, ref: 'Book' }] // Array of references }); // Alternative: Store reference only in Book (recommended) const bookSchema = new Schema({ title: String, author: { type: Schema.Types.ObjectId, ref: 'Author' } }); // Query all books by an author const books = await Book.find({ author: authorId });

Many-to-Many: Books and Genres

JavaScriptconst genreSchema = new Schema({ name: String, description: String }); const bookSchema = new Schema({ title: String, genres: [{ type: Schema.Types.ObjectId, ref: 'Genre' }] // Array of references }); // Query books with certain genres const fantasyBooks = await Book.find({ genres: fantasyGenreId }).populate('genres'); // Query genres of a specific book const book = await Book.findById(bookId).populate('genres');

9. Complete Example: Blog API with Mongoose

Here's a complete Express.js API using Mongoose with models, relationships, and validation:

models/User.js

JavaScript — models/User.jsconst mongoose = require('mongoose'); const userSchema = new mongoose.Schema({ name: { type: String, required: [true, 'Name is required'], trim: true, minlength: [2, 'Name must be at least 2 characters'] }, email: { type: String, required: [true, 'Email is required'], unique: true, lowercase: true, match: [/^\\S+@\\S+\\.\\S+$/, 'Please enter a valid email'] }, password: { type: String, required: [true, 'Password is required'], minlength: [6, 'Password must be at least 6 characters'], select: false // Don't include password in queries by default }, role: { type: String, enum: ['user', 'admin'], default: 'user' }, createdAt: { type: Date, default: Date.now } }, { toJSON: { virtuals: true }, toObject: { virtuals: true } }); // Virtual for posts by this user userSchema.virtual('posts', { ref: 'Post', localField: '_id', foreignField: 'author' }); module.exports = mongoose.model('User', userSchema);

models/Post.js

JavaScript — models/Post.jsconst 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'] }, content: { type: String, required: [true, 'Content is required'] }, author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, categories: [{ type: String, trim: true }], tags: [String], published: { type: Boolean, default: false }, publishedAt: Date, views: { type: Number, default: 0 }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now } }); // Update the updatedAt field on save postSchema.pre('save', function(next) { this.updatedAt = new Date(); next(); }); // Instance method postSchema.methods.publish = function() { this.published = true; this.publishedAt = new Date(); return this.save(); }; // Static method postSchema.statics.findPublished = function() { return this.find({ published: true }).sort({ publishedAt: -1 }); }; module.exports = mongoose.model('Post', postSchema);

models/Comment.js

JavaScript — models/Comment.jsconst mongoose = require('mongoose'); const commentSchema = new mongoose.Schema({ content: { type: String, required: true, maxlength: 1000 }, author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, post: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', required: true }, createdAt: { type: Date, default: Date.now } }); module.exports = mongoose.model('Comment', commentSchema);

routes/posts.js

JavaScript — routes/posts.jsconst express = require('express'); const router = express.Router(); const Post = require('../models/Post'); const Comment = require('../models/Comment'); // GET all posts (with filters) router.get('/', async (req, res) => { try { const { published, category, author, page = 1, limit = 10 } = req.query; // Build query const query = {}; if (published) query.published = published === 'true'; if (category) query.categories = category; if (author) query.author = author; const posts = await Post.find(query) .populate('author', 'name email') .sort({ createdAt: -1 }) .limit(limit * 1) .skip((page - 1) * limit); const total = await Post.countDocuments(query); res.json({ status: 'success', data: posts, pagination: { page: parseInt(page), limit: parseInt(limit), total, pages: Math.ceil(total / limit) } }); } catch (error) { res.status(500).json({ status: 'error', message: error.message }); } }); // GET single post with comments router.get('/:id', async (req, res) => { try { const post = await Post.findById(req.params.id) .populate('author', 'name email') .populate({ path: 'comments', populate: { path: 'author', select: 'name' } }); if (!post) { return res.status(404).json({ status: 'error', message: 'Post not found' }); } // Increment view count post.views += 1; await post.save(); res.json({ status: 'success', data: post }); } catch (error) { res.status(500).json({ status: 'error', message: error.message }); } }); // CREATE new post router.post('/', async (req, res) => { try { const { title, content, author, categories, tags } = req.body; const post = await Post.create({ title, content, author, categories, tags }); const populatedPost = await post.populate('author', 'name email'); res.status(201).json({ status: 'success', data: populatedPost }); } catch (error) { if (error.name === 'ValidationError') { return res.status(400).json({ status: 'error', errors: error.errors }); } res.status(500).json({ status: 'error', message: error.message }); } }); // UPDATE post router.put('/:id', async (req, res) => { try { const { title, content, categories, tags, published } = req.body; const post = await Post.findByIdAndUpdate( req.params.id, { title, content, categories, tags, published, updatedAt: new Date() }, { new: true, runValidators: true } ).populate('author', 'name email'); if (!post) { return res.status(404).json({ status: 'error', message: 'Post not found' }); } res.json({ status: 'success', data: post }); } catch (error) { res.status(500).json({ status: 'error', message: error.message }); } }); // DELETE post router.delete('/:id', async (req, res) => { try { const post = await Post.findByIdAndDelete(req.params.id); if (!post) { return res.status(404).json({ status: 'error', message: 'Post not found' }); } // Delete associated comments await Comment.deleteMany({ post: req.params.id }); res.status(204).send(); } catch (error) { res.status(500).json({ status: 'error', message: error.message }); } }); // POST comment on a post router.post('/:id/comments', async (req, res) => { try { const { content, author } = req.body; const comment = await Comment.create({ content, author, post: req.params.id }); await comment.populate('author', 'name'); res.status(201).json({ status: 'success', data: comment }); } catch (error) { res.status(500).json({ status: 'error', message: error.message }); } }); module.exports = router;

server.js

JavaScript — server.jsconst express = require('express'); const mongoose = require('mongoose'); const dotenv = require('dotenv'); dotenv.config(); const app = express(); app.use(express.json()); // Import routes const postRoutes = require('./routes/posts'); app.use('/api/posts', postRoutes); // Database connection mongoose.connect(process.env.MONGODB_URI) .then(() => { console.log('Connected to MongoDB'); app.listen(3000, () => console.log('Server running on port 3000')); }) .catch(err => console.error('Connection error:', err));

10. Best Practices and Next Steps

Best Practices for Mongoose

  • Use environment variables for connection strings
  • Enable runValidators: true in update operations to ensure validation runs
  • Use .lean() when you don't need Mongoose documents for better performance
  • Index frequently queried fields for faster reads
  • Handle validation errors gracefully with proper responses
  • Use virtuals for computed fields instead of storing them
  • Implement soft delete for recoverable deletions
  • Always use try/catch with async database operations

Testing with Mongoose

Use Jest and Supertest to test your Mongoose models and API endpoints:

JavaScript — tests/post.test.jsconst request = require('supertest'); const mongoose = require('mongoose'); const app = require('../app'); beforeEach(async () => { await mongoose.connect(process.env.MONGODB_URI); }); afterEach(async () => { await mongoose.connection.close(); }); describe('POST /api/posts', () => { it('should create a new post', async () => { const res = await request(app) .post('/api/posts') .send({ title: 'Test Post', content: 'Test content', author: '507f1f77bcf86cd799439011' }); expect(res.statusCode).toBe(201); expect(res.body.data.title).toBe('Test Post'); }); });

Next Steps

  • Add indexes for performance optimization
  • Implement authentication with JWT and password hashing (bcrypt)
  • Add pagination for large collections
  • Use MongoDB transactions for multi-document operations
  • Implement caching with Redis
  • Add API documentation with Swagger/OpenAPI
  • Set up logging with Morgan and Winston

11. Conclusion

You've now learned the fundamentals of using MongoDB with Mongoose in Express.js applications:

Key Takeaways — MongoDB vs Mongoose

  • MongoDB is the database (stores JSON-like documents)
  • Mongoose is the ODM (provides schema validation, models, query builder)
  • Schemas define document structure, field types, and validation rules
  • Models provide the interface to perform CRUD operations on collections
  • Validation happens at the schema level before documents are saved
  • Population allows referencing documents from other collections

Quick Reference

JavaScript// Schema definition const Schema = mongoose.Schema; const userSchema = new Schema({ name: String }); // Model creation const User = mongoose.model('User', userSchema); // CRUD await User.create({ name: 'John' }); // CREATE await User.find({ name: 'John' }); // READ await User.updateOne({ name: 'John' }, { $set: { age: 30 } }); // UPDATE await User.deleteOne({ name: 'John' }); // DELETE // Relationships const postSchema = new Schema({ author: { type: Schema.Types.ObjectId, ref: 'User' } }); await Post.find().populate('author');