MongoDB with Node.js

Learn how to use MongoDB in your Node.js applications! Master the MongoDB driver, Mongoose ODM, indexing, aggregation, and schema design patterns for production-ready apps.

Prerequisites

Make sure you understand MongoDB basics and CRUD operations. If you haven't, check out the first!

MongoDB Node.js Driver

While the MongoDB shell is great for learning, real applications need to interact with MongoDB programmatically. The MongoDB Node.js driver allows you to perform all database operations directly from your JavaScript code.

Installation

bash
# Install the MongoDB driver
npm install mongodb
# Optional: Install dotenv for environment variables
npm install dotenv

Basic Connection

The first step is connecting to your MongoDB database. You create a MongoClient and connect to your database server.

javascript
const { MongoClient } = require('mongodb');
// Connection string for local MongoDB
const uri = 'mongodb://localhost:27017';
// Or for MongoDB Atlas (cloud)
// const uri = 'mongodb+srv://username:password@cluster.mongodb.net/myDatabase';
const client = new MongoClient(uri);
async function run() {
try {
// Connect to MongoDB
await client.connect();
console.log('Connected to MongoDB!');
// Access a database
const db = client.db('schoolDB');
// Access a collection
const students = db.collection('students');
// Now you can perform operations!
const result = await students.findOne({ name: 'Sarah' });
console.log(result);
} catch (error) {
console.error('Error:', error);
} finally {
// Always close the connection when done
await client.close();
}
}
run();

Using Environment Variables

Never hardcode your connection string! Store it in environment variables for security.

bash
# Create a .env file in your project root
MONGODB_URI=mongodb://localhost:27017/schoolDB
javascript
// app.js
require('dotenv').config();
const { MongoClient } = require('mongodb');
// Load connection string from environment variable
const uri = process.env.MONGODB_URI;
const client = new MongoClient(uri);
async function run() {
try {
await client.connect();
const db = client.db(); // Uses database from URI
// Your code here...
} finally {
await client.close();
}
}
run();

CRUD Operations with Node.js Driver

All the CRUD operations you learned in the shell work the same way in Node.js! The syntax is almost identical.

javascript
const { MongoClient } = require('mongodb');
const uri = 'mongodb://localhost:27017';
const client = new MongoClient(uri);
async function crudExamples() {
await client.connect();
const db = client.db('schoolDB');
const students = db.collection('students');
// CREATE - Insert documents
await students.insertOne({
name: 'John Doe',
age: 20,
major: 'Computer Science'
});
await students.insertMany([
{ name: 'Jane Smith', age: 21, major: 'Mathematics' },
{ name: 'Bob Johnson', age: 19, major: 'Physics' }
]);
// READ - Find documents
const student = await students.findOne({ name: 'John Doe' });
console.log(student);
const allStudents = await students.find({}).toArray();
console.log(allStudents);
const csStudents = await students.find({
major: 'Computer Science'
}).toArray();
// UPDATE - Modify documents
await students.updateOne(
{ name: 'John Doe' },
{ $set: { age: 21 } }
);
await students.updateMany(
{ major: 'Computer Science' },
{ $set: { department: 'CS Dept' } }
);
// DELETE - Remove documents
await students.deleteOne({ name: 'Bob Johnson' });
await students.deleteMany({ age: { $lt: 18 } });
await client.close();
}
crudExamples();

Important: .toArray()

When using find() in Node.js, it returns a cursor (not the actual data). You need to call .toArray() to get the actual documents as an array. With findOne(), you get the document directly.

Using Mongoose ODM

Mongoose is an Object Data Modeling (ODM) library that makes working with MongoDB easier. It adds structure, validation, and many helpful features on top of the MongoDB driver.

Why Use Mongoose?

  • Schema Validation - Define structure and rules for your data
  • Type Casting - Automatic conversion to correct data types
  • Middleware - Run code before/after database operations
  • Query Building - Chainable methods for complex queries
  • Better for Large Apps - More structure and organization

Installation

bash
npm install mongoose

Connecting with Mongoose

javascript
const mongoose = require('mongoose');
// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/schoolDB')
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('Connection error:', err));
// Or using async/await
async function connectDB() {
try {
await mongoose.connect('mongodb://localhost:27017/schoolDB');
console.log('Connected to MongoDB');
} catch (error) {
console.error('Error:', error);
}
}
connectDB();

Defining a Schema

A schema defines the structure of your documents. It's like a blueprint that tells MongoDB what fields your documents should have and what types they should be.

javascript
const mongoose = require('mongoose');
// Define a schema
const studentSchema = new mongoose.Schema({
name: {
type: String,
required: true, // This field is required
trim: true // Remove whitespace
},
email: {
type: String,
required: true,
unique: true, // Must be unique
lowercase: true // Convert to lowercase
},
age: {
type: Number,
min: 0, // Minimum value
max: 120 // Maximum value
},
major: String, // Simple field definition
gpa: Number,
enrollmentDate: {
type: Date,
default: Date.now // Default to current date
},
hobbies: [String], // Array of strings
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true // Adds createdAt and updatedAt automatically
});
// Create a model from the schema
const Student = mongoose.model('Student', studentSchema);
module.exports = Student;

CRUD with Mongoose

Mongoose provides cleaner, more intuitive methods for CRUD operations:

javascript
const Student = require('./models/Student');
async function mongooseExamples() {
// CREATE
// Method 1: Using create()
const student1 = await Student.create({
name: 'Alice',
email: 'alice@university.edu',
age: 20,
major: 'Computer Science'
});
// Method 2: Using new and save()
const student2 = new Student({
name: 'Bob',
email: 'bob@university.edu',
age: 21
});
await student2.save();
// READ
// Find all
const allStudents = await Student.find();
// Find with criteria
const csStudents = await Student.find({ major: 'Computer Science' });
// Find one
const alice = await Student.findOne({ name: 'Alice' });
// Find by ID
const student = await Student.findById('507f1f77bcf86cd799439011');
// With query methods (chainable)
const results = await Student
.find({ age: { $gte: 20 } })
.sort({ name: 1 })
.limit(10)
.select('name email'); // Only return these fields
// UPDATE
// Find and update (returns updated document)
const updated = await Student.findOneAndUpdate(
{ name: 'Alice' },
{ age: 21 },
{ new: true } // Return updated document
);
// Update by ID
await Student.findByIdAndUpdate(
'507f1f77bcf86cd799439011',
{ $inc: { age: 1 } }
);
// Update many
await Student.updateMany(
{ major: 'Computer Science' },
{ $set: { department: 'CS' } }
);
// DELETE
await Student.findOneAndDelete({ name: 'Bob' });
await Student.findByIdAndDelete('507f1f77bcf86cd799439011');
await Student.deleteMany({ age: { $lt: 18 } });
// Count documents
const count = await Student.countDocuments({ major: 'Computer Science' });
}
mongooseExamples();

Mongoose vs Native Driver

Choose based on your needs:

  • Use Mongoose if: You want structure, validation, and are building a larger app
  • Use Native Driver if: You want maximum flexibility and minimal overhead
Database Indexing

Indexes are special data structures that make finding documents much faster. Without indexes, MongoDB has to scan every single document to find what you're looking for. With indexes, it can jump directly to the right documents.

Think of it Like a Book Index

Imagine looking for the word "database" in a 500-page book. Without an index, you'd have to read every page. With an index at the back of the book, you can jump straight to the right pages. That's exactly what database indexes do!

When to Use Indexes

  • Fields you frequently search by (e.g., email, username)
  • Fields you sort by (e.g., createdAt, price)
  • Fields used in complex queries

Creating Indexes in Mongoose

javascript
const studentSchema = new mongoose.Schema({
email: {
type: String,
unique: true, // Automatically creates a unique index
index: true // Creates a regular index
},
name: {
type: String,
index: true // Good for searching by name
},
createdAt: {
type: Date,
index: true // Good for sorting by date
}
});
// Create a compound index (index on multiple fields)
studentSchema.index({ major: 1, age: -1 });
// 1 = ascending, -1 = descending
// Text index for full-text search
studentSchema.index({ bio: 'text' });

Creating Indexes with Native Driver

javascript
const students = db.collection('students');
// Single field index
await students.createIndex({ email: 1 });
// Unique index
await students.createIndex({ email: 1 }, { unique: true });
// Compound index
await students.createIndex({ major: 1, age: -1 });
// Text index
await students.createIndex({ bio: 'text' });
// View all indexes
const indexes = await students.indexes();
console.log(indexes);

Index Trade-offs

Indexes make reads faster but writes slower (because the index needs to be updated). They also take up disk space. Don't create indexes on every field - only on fields you actually search/sort by frequently!

Aggregation Framework

Aggregation allows you to process and analyze your data. Think of it like advanced filtering, grouping, and calculations - similar to what you might do in Excel with pivot tables or SQL with GROUP BY.

What is Aggregation?

Aggregation is like a pipeline where data flows through multiple stages. Each stage transforms or filters the data, and the output of one stage becomes the input of the next.

Basic Aggregation Example

Let's count how many students are in each major:

javascript
const Student = require('./models/Student');
// Count students by major
const majorCounts = await Student.aggregate([
{
$group: {
_id: '$major', // Group by major field
count: { $sum: 1 } // Count documents in each group
}
},
{
$sort: { count: -1 } // Sort by count (descending)
}
]);
// Result:
// [
// { _id: 'Computer Science', count: 45 },
// { _id: 'Mathematics', count: 32 },
// { _id: 'Physics', count: 28 }
// ]

Common Aggregation Stages

javascript
// Multi-stage aggregation pipeline
const results = await Student.aggregate([
// Stage 1: FILTER - Match only active students
{
$match: {
isActive: true,
age: { $gte: 18 }
}
},
// Stage 2: GROUP - Calculate statistics by major
{
$group: {
_id: '$major',
averageAge: { $avg: '$age' },
totalStudents: { $sum: 1 },
maxGPA: { $max: '$gpa' },
minGPA: { $min: '$gpa' }
}
},
// Stage 3: SORT - Sort by average age
{
$sort: { averageAge: -1 }
},
// Stage 4: LIMIT - Get top 5
{
$limit: 5
},
// Stage 5: PROJECT - Reshape the output
{
$project: {
major: '$_id',
avgAge: { $round: ['$averageAge', 1] },
students: '$totalStudents',
_id: 0
}
}
]);

Common Aggregation Operators

  • $match — Filter documents (like find())
  • $group — Group documents and calculate aggregates
  • $sort — Sort documents
  • $limit — Limit number of results
  • $skip — Skip documents
  • $project — Reshape documents, select fields
  • $lookup — Join with another collection
  • $unwind — Deconstruct arrays
Schema Design Patterns

Even though MongoDB is flexible, good schema design is crucial for performance. The main decision is whether to embed related data or reference it.

Pattern 1: Embedding (Nesting)

Store related data inside the same document. Good when data is accessed together.

javascript
// User with embedded address
{
_id: ObjectId("123"),
name: "Alice",
email: "alice@example.com",
address: {
street: "123 Main St",
city: "New York",
zipCode: "10001",
country: "USA"
},
hobbies: ["reading", "coding", "gaming"]
}
// Use embedding when:
// - Related data is always accessed together
// - One-to-one or one-to-few relationships
// - Data doesn't grow unbounded
// - Data isn't shared across documents

Advantage: Get all data in one query (fast!)
Disadvantage: Duplicated data if the same address is used by multiple users

Pattern 2: Referencing (Linking)

Store related data in separate collections and link them with IDs. Good for data that's shared or grows large.

javascript
// User collection
{
_id: ObjectId("123"),
name: "Alice",
email: "alice@example.com"
}
// Posts collection (references user)
{
_id: ObjectId("456"),
title: "Learning MongoDB",
content: "MongoDB is awesome...",
authorId: ObjectId("123"), // Reference to user
createdAt: ISODate("2024-01-15")
}
// Use referencing when:
// - Data is large or grows unbounded
// - One-to-many or many-to-many relationships
// - Data is shared across multiple documents
// - Data is updated frequently

Advantage: No duplication, easier to update
Disadvantage: Requires multiple queries (slower)

Example: Blog Schema Design

javascript
// User Schema
const userSchema = new mongoose.Schema({
name: String,
email: String,
bio: String
});
// Post Schema (with references and embedding)
const postSchema = new mongoose.Schema({
title: String,
content: String,
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User' // Reference to User model
},
comments: [{ // Embed comments (one-to-few)
user: String,
text: String,
date: Date
}],
tags: [String], // Embed tags (simple data)
likes: Number,
createdAt: Date
});
// Using populate to get referenced data
const posts = await Post.find()
.populate('author', 'name email') // Get author details
.exec();

Design Guidelines

  • Consider your query patterns - how will you access the data?
  • Embed for high read-to-write ratio
  • Reference for frequently updated data
  • Document size limit is 16MB
  • Avoid deeply nested structures (>100 levels)
Best Practices

1. Connection Management

  • Reuse connections - don't create a new connection for every query
  • Use connection pooling (default in most drivers)
  • Always close connections when shutting down your app

2. Error Handling

javascript
async function safeOperation() {
try {
const result = await Student.findOne({ email: 'test@example.com' });
if (!result) {
throw new Error('Student not found');
}
return result;
} catch (error) {
console.error('Database error:', error.message);
throw error; // Re-throw for caller to handle
}
}

3. Security

  • Never expose connection strings in code
  • Use environment variables for sensitive data
  • Add .env to .gitignore
  • Use authentication in production
  • Limit network access (use IP whitelist)

4. Performance Tips

  • Create indexes on frequently queried fields
  • Use projection to fetch only needed fields
  • Use lean() in Mongoose for faster queries when you don't need Mongoose features
  • Limit and paginate large result sets
  • Use aggregation for complex data processing

5. Data Validation

  • Define schemas with proper validation rules
  • Use required fields where appropriate
  • Add min/max constraints for numbers
  • Use enum for fields with limited values
  • Validate data before saving

You've Mastered MongoDB!

Congratulations! You now know how to:

  • Use MongoDB with Node.js and Mongoose
  • Design efficient schemas
  • Create indexes for better performance
  • Use aggregation for data analysis
  • Follow best practices for production apps

Now you're ready to build real applications with MongoDB! Practice by building projects like a blog, todo app, or e-commerce store.

Built for learning — keep experimenting!