Express.js MongoDB & Mongoose
Schemas, models, CRUD operations, validation, and population with MongoDB
MongoDB
Mongoose
Schemas
CRUD
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 Database | MongoDB |
| Table | Collection |
| Row | Document |
| Column | Field |
| Primary Key | _id field |
| JOIN | $lookup or populate() |
| Fixed Schema | Dynamic 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:
- Download MongoDB Community Edition from mongodb.com
- Run the installer (choose "Complete" setup)
- Add MongoDB to PATH
- 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:
- Go to mongodb.com/atlas
- Sign up for a free account
- Create a new cluster (Shared/Free tier is sufficient for learning)
- Choose cloud provider (AWS, GCP, or Azure) and region
- Create a database user (username/password)
- Add your IP address to the network access list
- Click "Connect" → "Connect your application"
- 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
| Component | Description |
mongodb+srv:// | Protocol for SRV connection |
username:password | Authentication credentials |
cluster-url | Your Atlas cluster address |
database-name | Optional default database |
?retryWrites=true | Query 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:
| SchemaType | Description | Example |
| String | UTF-8 string | "John Doe" |
| Number | Integer or float | 42, 3.14 |
| Date | Date object | new Date() |
| Boolean | true/false | true |
| ObjectId | MongoDB document ID | new mongoose.Types.ObjectId() |
| Array | Array of values | [1, 2, 3] |
| Mixed | Any type (flexible) | { any: 'value' } |
| Map | Key-value pairs | new 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:
| Option | Description | Example |
| required | Field must be present | required: true |
| default | Default value if not provided | default: 'Anonymous' |
| unique | Create unique index | unique: true |
| lowercase | Convert string to lowercase | lowercase: true |
| uppercase | Convert string to uppercase | uppercase: true |
| trim | Remove whitespace | trim: true |
| min / max | Number range | min: 0, max: 100 |
| minlength / maxlength | String length | minlength: 3 |
| enum | Allowed values | enum: ['admin', 'user'] |
| match | Regular expression | match: /^[a-z]+$/ |
| get / set | Custom getters/setters | get: 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:
| Model | Document |
| Represents the entire collection | Represents 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 collection | Many 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:
| Operator | Meaning | Example |
| $eq | Equal to | { age: { $eq: 30 } } |
| $ne | Not equal to | { age: { $ne: 30 } } |
| $gt | Greater than | { age: { $gt: 18 } } |
| $gte | Greater than or equal | { age: { $gte: 18 } } |
| $lt | Less than | { age: { $lt: 65 } } |
| $lte | Less than or equal | { age: { $lte: 65 } } |
| $in | In array | { role: { $in: ['admin', 'moderator'] } } |
| $nin | Not in array | { role: { $nin: ['banned'] } } |
| $or | Logical OR | { $or: [{ age: 25 }, { age: 30 }] } |
| $and | Logical 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');