Build a Bookstore Inventory API

Build a REST API with MongoDB Atlas, Mongoose, and aggregation pipelines.

Introduction

30 Second Summary

Every online store needs a way to organize its catalog, compute ratings, and let customers search by keyword. The database powering that store needs to handle flexible, nested data and complex queries without grinding to a halt.

In this project, you will build a bookstore inventory API that stores books and authors in MongoDB, performs analytics with aggregation pipelines, and enables fast text search with indexes.

What You'll Build

A REST API built with Express and Mongoose that manages books and authors in a MongoDB Atlas database, complete with embedded reviews, cross-collection references, and analytics endpoints.

By the end of this project, you'll have:

  • A MongoDB Atlas free-tier cluster with a bookstore database connected to your Node.js app.
  • A full CRUD REST API with Mongoose models that demonstrate embedded documents and referenced relationships.
  • Aggregation pipeline endpoints that compute analytics like average ratings by genre and top authors by book count.
  • A text index on book titles and descriptions powering a search endpoint, with explain() output proving the performance gain.
  • Secret Mission: Add filtering and pagination to handle real-world query patterns.

Are there any prerequisites?

This project assumes you have basic JavaScript and Node.js experience, plus familiarity with REST APIs. You do not need any prior MongoDB knowledge.

Before We Start

Before We Start...

Set Up Your Project and MongoDB Atlas

Every API needs a database to store its data. For this bookstore inventory system, you need a place to store books, authors, and reviews that your Express server can talk to.

In this step, you will create a free MongoDB Atlas cloud database and connect it to a brand new Node.js project. By the end, your server will be running and talking to a live database.

In this step, get ready to:
  • Create a MongoDB Atlas account and free cluster.
  • Initialize a Node.js project and install dependencies.
  • Create the Express server and verify MongoDB connectivity.
Create a MongoDB Atlas account and free cluster

MongoDB Atlas is a cloud-hosted database service. It gives you a live MongoDB database without needing to install anything on your machine.

  • Open your browser and go to cloud.mongodb.com.
  • Click Sign Up and create a free account (no credit card required).
  • Complete any welcome prompts that appear after signing in.

What is MongoDB Atlas?

MongoDB Atlas is a Database-as-a-Service (DBaaS) that hosts your MongoDB databases in the cloud. The free tier gives you 512 MB of storage, which is more than enough for this project.

Atlas handles backups, scaling, and security patches so you can focus on building your API.

  • Click the Create button to start creating a cluster.
  • Select the M0 (Free) option.
  • Under provider, select AWS.
  • Select the region closest to you from the available options.
  • Leave the cluster name as the default or enter a custom name.
  • Click Create to deploy your cluster.

The Security Quickstart wizard appears automatically after deployment. This takes less than 15 seconds.

  • In the Username field, enter a username for your database user.
  • In the Password field, enter a password or copy the secure password that Atlas suggests.
  • Save your username and password somewhere safe. You will need them for your connection string.
  • Click Create Database User.

Save your credentials now

Write down or save the database username and password immediately. Atlas only shows the suggested password once. You will paste these into your connection string shortly.

  • Click Add My Current IP Address to allow your computer to connect to the cluster.
  • Click Finish and Close.
  • Click Go to Overview.

Why allow your IP address?

Atlas blocks all incoming connections by default. Adding your IP address tells Atlas to accept connections from your computer. For development purposes, you can also allow all IPs by adding 0.0.0.0/0 to the access list, but allowing only your IP is more secure.

Now grab your connection string so your Node.js app can connect to this cluster.

  • On the cluster overview page, click the Connect button next to your cluster name.
  • Select Drivers as your connection method.
  • Copy the connection string that starts with mongodb+srv://.
  • Keep this string handy. You will paste it into your project's .env file next.

Don't see the Connect button?

Your cluster may still be deploying. Wait a few seconds and refresh the page. The Connect button appears once the cluster is ready.

Help me connect to my MongoDB Atlas cluster.

Initialize Node.js project and install dependencies

With your database ready in the cloud, you need a local project that can connect to it. You will set up a Node.js project with Express for the API server and Mongoose as the MongoDB driver.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir bookstore-api && cd bookstore-api
  • Initialize a new Node.js project by running:
npm init -y

What does npm init -y do?

This command creates a package.json file with default values. The -y flag skips the interactive prompts and accepts all defaults. You will edit this file in a moment.

  • Install the three packages your project needs by running:
npm install express@5.2.1 mongoose@9.7.0 dotenv@17.4.2

What are these packages?

  • express@5.2.1 is a web framework for building REST APIs.
  • mongoose@9.7.0 is the MongoDB driver for Node.js. It provides schema validation and query helpers.
  • dotenv@17.4.2 loads environment variables from a .env file so you can keep secrets out of your code.

You should see output confirming the packages were added to your node_modules folder.

Seeing permission errors or network issues?

If npm install fails, check your internet connection. If you see EACCES permission errors on macOS, try prefixing the command with sudo.

Help me fix my npm install error.

Now you need to configure your package.json to use ES modules (the import syntax instead of require).

  • Open your bookstore-api folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the bookstore-api folder on your Desktop, then click Open.
  • Open package.json in the VS Code Explorer sidebar.
  • Add "type": "module" after the "version" line. Also update the "description" and "scripts" sections so your file matches this:
{
  "name": "bookstore-api",
  "version": "1.0.0",
  "type": "module",
  "description": "Bookstore inventory API with MongoDB and Mongoose",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "seed": "node seed.js"
  },
  "dependencies": {
    "dotenv": "17.4.2",
    "express": "5.2.1",
    "mongoose": "9.7.0"
  }
}

Why add type module?

Adding "type": "module" tells Node.js to use ES module syntax (import/export) instead of CommonJS (require/module.exports). ES modules are the modern JavaScript standard.

Next, create the environment and ignore files to keep your project secure.

  • Create a new file called .env in the root of your bookstore-api folder. In VS Code, right-click in the Explorer sidebar and select New File.
  • Name the file .env and paste the following content:
MONGODB_URI=mongodb+srv://your-username:your-password@cluster0.xxxxx.mongodb.net/bookstore?retryWrites=true&w=majority
PORT=3000
  • Replace your-username with the database username you created in Atlas.
  • Replace your-password with the database password you saved.
  • Replace cluster0.xxxxx.mongodb.net with the hostname from your Atlas connection string.
  • Make sure the database name after the / is bookstore.

What is a .env file?

A .env file stores configuration values (like database credentials) outside of your code. The dotenv package reads this file and makes the values available via process.env. This keeps secrets out of version control.

  • Create another new file called .gitignore in the root of your project. Right-click in the Explorer sidebar and select New File.
  • Name it .gitignore and paste the following content:
node_modules
.env

Why ignore these files?

The node_modules folder contains thousands of dependency files that can be regenerated with npm install. The .env file contains your database credentials. Neither should ever be committed to Git.

  • Save both files with Cmd+S (macOS) or Ctrl+S (Windows).
Create the entry point and verify connection

The entry point is where your server starts. It connects to MongoDB, sets up Express, and begins listening for requests.

  • Create a new file called server.js in the root of your bookstore-api folder. Right-click in the Explorer sidebar and select New File.
  • Name it server.js and paste the following code:
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';

const app = express();
const PORT = process.env.PORT || 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Connect to MongoDB Atlas and start the server
mongoose.connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB Atlas');
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err.message);
    process.exit(1);
  });

What does this code do?

  • import 'dotenv/config' loads your .env variables into process.env automatically.
  • express.json() is middleware that parses JSON request bodies so your routes can read req.body.
  • mongoose.connect() returns a Promise. If the connection succeeds, the server starts listening. If it fails, the process exits with an error.
  • Save server.js with Cmd+S (macOS) or Ctrl+S (Windows).
  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key).
  • Start your server by running:
node server.js
  • You should see two lines printed in your terminal:
Connected to MongoDB Atlas
Server running on port 3000

Seeing a connection error?

If you see MongoDB connection error in your terminal, check the following:

  • Verify your MONGODB_URI in .env has the correct username, password, and cluster hostname.
  • Make sure you replaced <password> with your actual database user password (not your Atlas login password).
  • Confirm your IP address is allowlisted in Atlas under Network Access.

Help me debug my MongoDB Atlas connection error.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files (package.json, .env, and server.js).

ⓧ I'd like to double check the full code

Here is the full code for each file at this point in the project:

{
  "name": "bookstore-api",
  "version": "1.0.0",
  "type": "module",
  "description": "Bookstore inventory API with MongoDB and Mongoose",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "seed": "node seed.js"
  },
  "dependencies": {
    "dotenv": "17.4.2",
    "express": "5.2.1",
    "mongoose": "9.7.0"
  }
}
MONGODB_URI=mongodb+srv://your-username:your-password@cluster0.xxxxx.mongodb.net/bookstore?retryWrites=true&w=majority
PORT=3000
node_modules
.env
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';

const app = express();
const PORT = process.env.PORT || 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Connect to MongoDB Atlas and start the server
mongoose.connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB Atlas');
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err.message);
    process.exit(1);
  });

Your server is live and connected to a real database in the cloud. Next up, you will define the Mongoose models that shape your bookstore data.

Define Mongoose Models

Your server is connected to MongoDB Atlas. Now it's time to define how your data looks.

In MongoDB, data is stored as flexible JSON-like documents. Mongoose adds structure on top of that flexibility by letting you define schemas. Schemas enforce rules about what fields exist, what types they hold, and what values are valid.

In this step, get ready to:
  • Create an Author model with required fields and validation.
  • Create a Book model with embedded reviews and an author reference.
  • Add text and compound indexes for future search and query performance.
Create the Author model with schema validation

Each model lives in its own file inside a models directory. The Author model is the simpler of the two, defining a person who writes books.

  • Create the models directory by running this command:
mkdir models
  • Confirm the directory was created by running:
ls

You should see models listed alongside server.js, node_modules, and package.json.

  • Create a new file called Author.js inside the models folder and paste the following code:
import mongoose from 'mongoose';

// Define the shape of an Author document
const authorSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Author name is required'],
    unique: true,
    trim: true
  },
  biography: {
    type: String,
    default: ''
  },
  birthYear: {
    type: Number
  }
}, {
  timestamps: true
});

// Create the model from the schema
const Author = mongoose.model('Author', authorSchema);

export default Author;

What does this code do?

This file defines a Mongoose schema and exports it as a model that your routes will use to interact with the authors collection in MongoDB.

  • name is required and unique, so MongoDB rejects duplicate author names. The trim option strips leading and trailing whitespace before saving.
  • biography and birthYear are optional. If biography is omitted, it defaults to an empty string.
  • timestamps: true tells Mongoose to automatically add createdAt and updatedAt fields to every document.
  • Save models/Author.js.

You won't see a visible change yet. The next file uses this model as a reference.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import mongoose from 'mongoose';

const authorSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Author name is required'],
    unique: true,
    trim: true
  },
  biography: {
    type: String,
    default: ''
  },
  birthYear: {
    type: Number
  }
}, {
  timestamps: true
});

const Author = mongoose.model('Author', authorSchema);

export default Author;
Create the Book model with embedded reviews and author reference

The Book model is more complex. It demonstrates two different relationship patterns: embedding and referencing.

  • Create a new file called Book.js inside the models folder. Start by adding the imports and the review subdocument schema:
import mongoose from 'mongoose';

const { Schema } = mongoose;

// Subdocument schema for reviews embedded inside each book
const reviewSchema = new Schema({
  reviewer: { type: String, required: true },
  rating: { type: Number, required: true, min: 1, max: 5 },
  comment: { type: String, default: '' },
  date: { type: Date, default: Date.now }
}, { _id: true });

What does this code do?

The reviewSchema defines the shape of a review that lives inside a book document. Each review has a reviewer name, a rating between 1 and 5, an optional comment, and a date that defaults to the current time.

The { _id: true } option gives each review its own unique identifier. This makes it easier to update or delete individual reviews later.

  • Below the reviewSchema, add the main book schema, indexes, and model export:
// Main book schema with references and embedded subdocuments
const bookSchema = new Schema({
  title: {
    type: String,
    required: [true, 'Book title is required'],
    trim: true
  },
  author: {
    type: Schema.Types.ObjectId,
    ref: 'Author',
    required: [true, 'Author is required']
  },
  genre: {
    type: String,
    required: true,
    enum: ['Fiction', 'Non-Fiction', 'Science Fiction', 'Fantasy', 'Mystery', 'Biography', 'History', 'Self-Help']
  },
  publishedYear: {
    type: Number
  },
  description: {
    type: String,
    default: ''
  },
  price: {
    type: Number,
    required: true,
    min: 0
  },
  inStock: {
    type: Boolean,
    default: true
  },
  reviews: [reviewSchema]
}, {
  timestamps: true
});

// Text index for searching books by title or description
bookSchema.index({ title: 'text', description: 'text' });
// Compound index for filtering by genre and sorting by year
bookSchema.index({ genre: 1, publishedYear: -1 });

const Book = mongoose.model('Book', bookSchema);

export default Book;

What does this code do?

The bookSchema defines the full structure of a book document in your database.

  • author stores an ObjectId that points to a document in the authors collection. The ref: 'Author' tells Mongoose which model to use when you call .populate() later.
  • genre uses an enum to restrict values to a fixed list of valid genres.
  • reviews: [reviewSchema] embeds an array of review subdocuments directly inside each book.
  • The text index enables full-text search across title and description. The compound index speeds up queries that filter by genre and sort by year.
  • Save models/Book.js.
  • Stop your server with Ctrl+C and restart it by running:
node server.js

You should see Connected to MongoDB Atlas and Server running on port 3000 with no errors. This confirms both model files are valid JavaScript and Mongoose accepted the schemas.

Seeing a SyntaxError or module not found?

  • Check that your file is named exactly Book.js (capital B) inside the models folder. File paths are case-sensitive on most systems.
  • Make sure both files start with import mongoose from 'mongoose' and that your package.json still has "type": "module".
  • Confirm there are no missing commas or brackets in the schema definitions.

Ask for help: My server crashes after adding the Mongoose model files. Help me debug the error.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

import mongoose from 'mongoose';

const { Schema } = mongoose;

const reviewSchema = new Schema({
  reviewer: { type: String, required: true },
  rating: { type: Number, required: true, min: 1, max: 5 },
  comment: { type: String, default: '' },
  date: { type: Date, default: Date.now }
}, { _id: true });

const bookSchema = new Schema({
  title: {
    type: String,
    required: [true, 'Book title is required'],
    trim: true
  },
  author: {
    type: Schema.Types.ObjectId,
    ref: 'Author',
    required: [true, 'Author is required']
  },
  genre: {
    type: String,
    required: true,
    enum: ['Fiction', 'Non-Fiction', 'Science Fiction', 'Fantasy', 'Mystery', 'Biography', 'History', 'Self-Help']
  },
  publishedYear: {
    type: Number
  },
  description: {
    type: String,
    default: ''
  },
  price: {
    type: Number,
    required: true,
    min: 0
  },
  inStock: {
    type: Boolean,
    default: true
  },
  reviews: [reviewSchema]
}, {
  timestamps: true
});

bookSchema.index({ title: 'text', description: 'text' });
bookSchema.index({ genre: 1, publishedYear: -1 });

const Book = mongoose.model('Book', bookSchema);

export default Book;
Understand the modeling decision

You just used two different patterns for relating data. Choosing the right one is one of the most important decisions in MongoDB schema design.

Why embed reviews but reference authors?

Reviews are embedded directly inside each book document. This works well because reviews are always read together with the book, they belong to one book only, and a single book will never have thousands of reviews (bounded growth).

Authors are stored in a separate collection and referenced by ObjectId. This makes sense because an author exists independently of any single book. One author can write many books. If the author's biography changes, you update it once instead of in every book document.

When to embed vs reference?

  • Embed when the child data is always read with the parent, has bounded growth, and belongs to only one parent.
  • Reference when the data is shared across multiple documents, grows unboundedly, or needs to be queried independently.

Your data models are locked in. Next up, you'll build the API routes that let you create, read, update, and delete books and authors through HTTP requests.

Build CRUD Routes for Books and Authors

Your Mongoose models define how data is structured in MongoDB. Now you need a way for clients to actually interact with that data over HTTP.

In this step, you'll build Express route handlers that perform Create, Read, Update, and Delete operations using the Author and Book models from the previous step. You'll also use Mongoose's populate feature to resolve references between collections.

In this step, get ready to:
  • Create author routes with full CRUD operations.
  • Create book routes with CRUD, populate, and an embedded reviews endpoint.
  • Wire routes to Express and test with curl.
Create author routes with full CRUD

Each route handler maps an HTTP method and URL path to a Mongoose operation. The author routes will handle creating, reading, updating, and deleting author documents.

  • Create a routes folder inside your bookstore-api directory by running this command:
mkdir routes
  • Create a new file called authors.js inside the routes folder, and paste the following code:
import { Router } from 'express';
import Author from '../models/Author.js';

const router = Router();

// Create an author
router.post('/', async (req, res) => {
  try {
    const author = new Author(req.body);
    const saved = await author.save();
    res.status(201).json(saved);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// Get all authors
router.get('/', async (req, res) => {
  try {
    const authors = await Author.find();
    res.json(authors);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Get author by ID
router.get('/:id', async (req, res) => {
  try {
    const author = await Author.findById(req.params.id);
    if (!author) return res.status(404).json({ error: 'Author not found' });
    res.json(author);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Update an author
router.put('/:id', async (req, res) => {
  try {
    const author = await Author.findByIdAndUpdate(req.params.id, req.body, {
      new: true,
      runValidators: true
    });
    if (!author) return res.status(404).json({ error: 'Author not found' });
    res.json(author);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// Delete an author
router.delete('/:id', async (req, res) => {
  try {
    const author = await Author.findByIdAndDelete(req.params.id);
    if (!author) return res.status(404).json({ error: 'Author not found' });
    res.json({ message: 'Author deleted' });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

export default router;

What does this code do?

Each route handler follows the same pattern: wrap the Mongoose call in a try/catch, then return the result with an appropriate HTTP status code.

  • POST / creates a new Author document from the request body and returns 201 (Created).
  • GET / returns all authors. GET /:id finds one by its MongoDB ObjectId.
  • PUT /:id uses findByIdAndUpdate with new: true to return the updated document and runValidators: true to enforce schema rules on updates.
  • DELETE /:id removes the author and returns a confirmation message.
  • Save routes/authors.js.

Seeing a red underline on the import?

Make sure your package.json includes "type": "module" at the top level. Without it, Node.js treats files as CommonJS and ESM imports will fail.

Also confirm the relative path ../models/Author.js matches your folder structure exactly, including the .js extension.

Help me fix my ESM import error in routes/authors.js

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { Router } from 'express';
import Author from '../models/Author.js';

const router = Router();

// Create an author
router.post('/', async (req, res) => {
  try {
    const author = new Author(req.body);
    const saved = await author.save();
    res.status(201).json(saved);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// Get all authors
router.get('/', async (req, res) => {
  try {
    const authors = await Author.find();
    res.json(authors);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Get author by ID
router.get('/:id', async (req, res) => {
  try {
    const author = await Author.findById(req.params.id);
    if (!author) return res.status(404).json({ error: 'Author not found' });
    res.json(author);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Update an author
router.put('/:id', async (req, res) => {
  try {
    const author = await Author.findByIdAndUpdate(req.params.id, req.body, {
      new: true,
      runValidators: true
    });
    if (!author) return res.status(404).json({ error: 'Author not found' });
    res.json(author);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// Delete an author
router.delete('/:id', async (req, res) => {
  try {
    const author = await Author.findByIdAndDelete(req.params.id);
    if (!author) return res.status(404).json({ error: 'Author not found' });
    res.json({ message: 'Author deleted' });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

export default router;
Create book routes with CRUD and populate

The book routes follow the same CRUD pattern but add two important features. The .populate() method resolves the author reference into actual author data. The reviews endpoint demonstrates pushing new subdocuments into an embedded array.

  • Create a new file called books.js inside the routes folder, and paste this first section of code:
import { Router } from 'express';
import Book from '../models/Book.js';

const router = Router();

// Create a book
router.post('/', async (req, res) => {
  try {
    const book = new Book(req.body);
    const saved = await book.save();
    res.status(201).json(saved);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// Get all books (with populated author)
router.get('/', async (req, res) => {
  try {
    const books = await Book.find().populate('author', 'name');
    res.json(books);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Search books by text
router.get('/search', async (req, res) => {
  try {
    const { q } = req.query;
    if (!q) return res.status(400).json({ error: 'Query parameter q is required' });
    const books = await Book.find({ $text: { $search: q } })
      .populate('author', 'name');
    res.json(books);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

What's happening in these routes?

  • .populate('author', 'name') tells Mongoose to replace the ObjectId in the author field with the actual author document, returning only the name field.
  • The /search route uses MongoDB's $text operator to search across the text index you defined on title and description in the Book schema.
  • The /search route is defined BEFORE /:id. Order matters because Express matches routes top-to-bottom, and /:id would match the literal string "search" as an ID.
  • Below the search route in routes/books.js, add the remaining routes:
// Get book by ID
router.get('/:id', async (req, res) => {
  try {
    const book = await Book.findById(req.params.id).populate('author');
    if (!book) return res.status(404).json({ error: 'Book not found' });
    res.json(book);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Update a book
router.put('/:id', async (req, res) => {
  try {
    const book = await Book.findByIdAndUpdate(req.params.id, req.body, {
      new: true,
      runValidators: true
    });
    if (!book) return res.status(404).json({ error: 'Book not found' });
    res.json(book);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// Delete a book
router.delete('/:id', async (req, res) => {
  try {
    const book = await Book.findByIdAndDelete(req.params.id);
    if (!book) return res.status(404).json({ error: 'Book not found' });
    res.json({ message: 'Book deleted' });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Add a review to a book
router.post('/:id/reviews', async (req, res) => {
  try {
    const book = await Book.findById(req.params.id);
    if (!book) return res.status(404).json({ error: 'Book not found' });
    book.reviews.push(req.body);
    const saved = await book.save();
    res.status(201).json(saved);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

export default router;

How does the reviews endpoint work?

The POST /:id/reviews route demonstrates working with embedded subdocuments. Instead of creating a separate collection, it pushes a new review object directly into the book's reviews array.

This works because reviews belong to a single book and are always accessed alongside it. Mongoose validates the subdocument against your reviewSchema when you call .save().

  • Save routes/books.js.

Seeing a syntax error?

Make sure you pasted the second block of code directly below the first, with no duplicate import statements or const router declarations. The file should have one set of imports at the top and one export default router at the bottom.

Help me fix a syntax error in my routes/books.js file

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { Router } from 'express';
import Book from '../models/Book.js';

const router = Router();

// Create a book
router.post('/', async (req, res) => {
  try {
    const book = new Book(req.body);
    const saved = await book.save();
    res.status(201).json(saved);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// Get all books (with populated author)
router.get('/', async (req, res) => {
  try {
    const books = await Book.find().populate('author', 'name');
    res.json(books);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Search books by text
router.get('/search', async (req, res) => {
  try {
    const { q } = req.query;
    if (!q) return res.status(400).json({ error: 'Query parameter q is required' });
    const books = await Book.find({ $text: { $search: q } })
      .populate('author', 'name');
    res.json(books);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Get book by ID
router.get('/:id', async (req, res) => {
  try {
    const book = await Book.findById(req.params.id).populate('author');
    if (!book) return res.status(404).json({ error: 'Book not found' });
    res.json(book);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Update a book
router.put('/:id', async (req, res) => {
  try {
    const book = await Book.findByIdAndUpdate(req.params.id, req.body, {
      new: true,
      runValidators: true
    });
    if (!book) return res.status(404).json({ error: 'Book not found' });
    res.json(book);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// Delete a book
router.delete('/:id', async (req, res) => {
  try {
    const book = await Book.findByIdAndDelete(req.params.id);
    if (!book) return res.status(404).json({ error: 'Book not found' });
    res.json({ message: 'Book deleted' });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Add a review to a book
router.post('/:id/reviews', async (req, res) => {
  try {
    const book = await Book.findById(req.params.id);
    if (!book) return res.status(404).json({ error: 'Book not found' });
    book.reviews.push(req.body);
    const saved = await book.save();
    res.status(201).json(saved);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

export default router;
Wire routes to Express and test

Your route files exist but Express doesn't know about them yet. You need to import them and mount them on URL prefixes in server.js.

  • Open server.js and replace its contents with the following updated version:
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import authorRoutes from './routes/authors.js';
import bookRoutes from './routes/books.js';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.use('/api/authors', authorRoutes);
app.use('/api/books', bookRoutes);

mongoose.connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB Atlas');
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err.message);
    process.exit(1);
  });

What changed in server.js?

  • Two new imports bring in the route modules from the routes folder.
  • app.use('/api/authors', authorRoutes) mounts all author routes under the /api/authors prefix. A POST to / in the router becomes POST /api/authors externally.
  • app.use('/api/books', bookRoutes) does the same for all book routes under /api/books.
  • Save server.js.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node server.js

You should see "Connected to MongoDB Atlas" and "Server running on port 3000" in the terminal.

Now test the endpoints. Open a second terminal window to run the following curl commands while the server stays running.

  • Create a test author by running:
curl -X POST http://localhost:3000/api/authors \
  -H "Content-Type: application/json" \
  -d '{"name": "George Orwell", "biography": "English novelist", "birthYear": 1903}'

You should see a JSON response with the new author document, including an _id field. Copy this _id value for the next command.

  • Create a book referencing that author by running this command, replacing the author ID with the one you copied:
curl -X POST http://localhost:3000/api/books \
  -H "Content-Type: application/json" \
  -d '{"title": "1984", "author": "[[AUTHOR_ID="the _id from the author you just created"]]", "genre": "Fiction", "publishedYear": 1949, "description": "A dystopian novel", "price": 12.99}'
  • Fetch all books and confirm the author data is populated by running:
curl http://localhost:3000/api/books

You should see the book document with the author field showing the author's name instead of just a raw ObjectId.

Author field shows null or just an ID?

If author is null, the ID you used when creating the book doesn't match any author in the database. Double check you copied the exact _id string from the create-author response.

If author shows a raw ObjectId string instead of { name: "George Orwell" }, make sure your GET / route includes .populate('author', 'name') after Book.find().

Help me debug why populate isn't working on my books route

✔️ Awesome, I've got everything!

Great. Make sure you've saved server.js before moving on.

ⓧ I'd like to double check the full code

import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import authorRoutes from './routes/authors.js';
import bookRoutes from './routes/books.js';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.use('/api/authors', authorRoutes);
app.use('/api/books', bookRoutes);

mongoose.connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB Atlas');
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err.message);
    process.exit(1);
  });

Why embedded reviews instead of a separate collection?

Reviews are always accessed alongside their book. You never query reviews independently across all books. Embedding them avoids an extra database lookup every time you fetch a book.

Authors, on the other hand, exist independently and are shared across many books. Referencing them with an ObjectId avoids duplicating author data in every book document.

Your API can now create, read, update, and delete both books and authors. Next up, you'll add aggregation pipeline endpoints that compute analytics across your data.

Add Aggregation Pipeline Endpoints

Your CRUD routes handle individual documents well, but a real bookstore needs analytics. Questions like "which genre has the highest ratings?" or "who are our most prolific authors?" require combining data from many documents at once.

MongoDB's aggregation pipeline processes documents through a series of stages, transforming and combining them step by step. In this step, you'll build two analytics endpoints that use multi-stage pipelines to answer questions no single-document query can.

In this step, get ready to:
  • Build an aggregation pipeline that calculates average ratings by genre.
  • Build an aggregation pipeline that finds the top authors by book count.
  • Seed the database with sample data and test both endpoints.
Create the average rating by genre endpoint

Each book has an array of embedded reviews, and each review has a rating. To find the average rating per genre, the pipeline needs to break apart those arrays, group by genre, and compute the average.

  • Create a new file called routes/analytics.js inside your bookstore-api folder.
  • Add the following code to set up the router and the ratings-by-genre endpoint:
import { Router } from 'express';
import Book from '../models/Book.js';

const router = Router();

// Average rating by genre
router.get('/ratings-by-genre', async (req, res) => {
  try {
    const results = await Book.aggregate([
      { $unwind: '$reviews' },
      {
        $group: {
          _id: '$genre',
          averageRating: { $avg: '$reviews.rating' },
          totalReviews: { $sum: 1 }
        }
      },
      { $sort: { averageRating: -1 } },
      {
        $project: {
          genre: '$_id',
          averageRating: { $round: ['$averageRating', 2] },
          totalReviews: 1,
          _id: 0
        }
      }
    ]);
    res.json(results);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

export default router;

What does this pipeline do?

The pipeline has four stages that process documents sequentially:

  • $unwind splits each book's reviews array into separate documents. A book with 3 reviews becomes 3 documents.
  • $group collects documents by genre, calculating the average rating and total review count for each group.
  • $sort orders the results from highest to lowest average rating.
  • $project reshapes the output to use clean field names and rounds the average to 2 decimal places.
  • Save routes/analytics.js.

You won't see a change yet. The next chunk adds the second endpoint, and then you'll wire the routes into your server to test them.

Add the top authors by book count endpoint

This pipeline answers a different kind of question. Instead of computing averages within a single collection, it counts books per author and then joins data from the authors collection to get author names.

  • In routes/analytics.js, find this line:
export default router;
  • Add the following endpoint above that line:
// Top authors by book count
router.get('/top-authors', async (req, res) => {
  try {
    const results = await Book.aggregate([
      {
        $group: {
          _id: '$author',
          bookCount: { $sum: 1 }
        }
      },
      { $sort: { bookCount: -1 } },
      { $limit: 5 },
      {
        $lookup: {
          from: 'authors',
          localField: '_id',
          foreignField: '_id',
          as: 'authorInfo'
        }
      },
      { $unwind: '$authorInfo' },
      {
        $project: {
          authorName: '$authorInfo.name',
          bookCount: 1,
          _id: 0
        }
      }
    ]);
    res.json(results);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

What does this pipeline do?

This pipeline uses six stages to cross-reference two collections:

  • $group counts how many books each author ObjectId has.
  • $sort and $limit keep only the top 5 authors.
  • $lookup joins the authors collection to get each author's full document. This is MongoDB's version of a SQL JOIN.
  • $unwind flattens the lookup result from an array to a single object.
  • $project outputs only the author name and book count.
  • Save routes/analytics.js.

Seeing a syntax error?

Make sure the new route handler sits above export default router; and that you have not accidentally duplicated the export line.

Help me debug a syntax error in my analytics routes file.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

import { Router } from 'express';
import Book from '../models/Book.js';

const router = Router();

// Average rating by genre
router.get('/ratings-by-genre', async (req, res) => {
  try {
    const results = await Book.aggregate([
      { $unwind: '$reviews' },
      {
        $group: {
          _id: '$genre',
          averageRating: { $avg: '$reviews.rating' },
          totalReviews: { $sum: 1 }
        }
      },
      { $sort: { averageRating: -1 } },
      {
        $project: {
          genre: '$_id',
          averageRating: { $round: ['$averageRating', 2] },
          totalReviews: 1,
          _id: 0
        }
      }
    ]);
    res.json(results);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Top authors by book count
router.get('/top-authors', async (req, res) => {
  try {
    const results = await Book.aggregate([
      {
        $group: {
          _id: '$author',
          bookCount: { $sum: 1 }
        }
      },
      { $sort: { bookCount: -1 } },
      { $limit: 5 },
      {
        $lookup: {
          from: 'authors',
          localField: '_id',
          foreignField: '_id',
          as: 'authorInfo'
        }
      },
      { $unwind: '$authorInfo' },
      {
        $project: {
          authorName: '$authorInfo.name',
          bookCount: 1,
          _id: 0
        }
      }
    ]);
    res.json(results);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

export default router;
Register analytics routes and seed the database

The analytics routes exist, but Express does not know about them yet. You also need sample data in your database so the aggregation pipelines have something to compute.

  • In server.js, find this line:
import bookRoutes from './routes/books.js';
  • Add this import directly below it:
import analyticsRoutes from './routes/analytics.js';
  • Now find this line:
app.use('/api/books', bookRoutes);
  • Add this route registration directly below it:
app.use('/api/analytics', analyticsRoutes);
  • Save server.js.

Next, you need sample data. Instead of creating authors and books one by one through the API, you'll write a seed script that populates the database in one command.

  • Create a new file called seed.js in the root of your bookstore-api folder.
  • Add the following code to create 5 authors and 6 books with embedded reviews:
import 'dotenv/config';
import mongoose from 'mongoose';
import Author from './models/Author.js';
import Book from './models/Book.js';

async function seed() {
  await mongoose.connect(process.env.MONGODB_URI);
  console.log('Connected to MongoDB Atlas');

  // Clear existing data
  await Author.deleteMany({});
  await Book.deleteMany({});

  // Create authors
  const authors = await Author.insertMany([
    { name: 'George Orwell', biography: 'English novelist and essayist', birthYear: 1903 },
    { name: 'J.K. Rowling', biography: 'British author of the Harry Potter series', birthYear: 1965 },
    { name: 'Isaac Asimov', biography: 'American writer and professor of biochemistry', birthYear: 1920 },
    { name: 'Agatha Christie', biography: 'English writer of detective novels', birthYear: 1890 },
    { name: 'Malcolm Gladwell', biography: 'Canadian journalist and author', birthYear: 1963 }
  ]);

  // Create books with embedded reviews
  await Book.insertMany([
    {
      title: '1984',
      author: authors[0]._id,
      genre: 'Fiction',
      publishedYear: 1949,
      description: 'A dystopian novel set in a totalitarian society',
      price: 12.99,
      inStock: true,
      reviews: [
        { reviewer: 'Alice', rating: 5, comment: 'A masterpiece of political fiction' },
        { reviewer: 'Bob', rating: 4, comment: 'Chilling and thought-provoking' }
      ]
    },
    {
      title: 'Animal Farm',
      author: authors[0]._id,
      genre: 'Fiction',
      publishedYear: 1945,
      description: 'An allegorical novella about a group of farm animals',
      price: 9.99,
      inStock: true,
      reviews: [
        { reviewer: 'Charlie', rating: 5, comment: 'Brilliant satire' }
      ]
    },
    {
      title: 'Harry Potter and the Philosophers Stone',
      author: authors[1]._id,
      genre: 'Fantasy',
      publishedYear: 1997,
      description: 'A young wizard discovers his magical heritage',
      price: 14.99,
      inStock: true,
      reviews: [
        { reviewer: 'Diana', rating: 5, comment: 'Magical world-building' },
        { reviewer: 'Eve', rating: 4, comment: 'Great for all ages' },
        { reviewer: 'Frank', rating: 5, comment: 'Captivating from start to finish' }
      ]
    },
    {
      title: 'Foundation',
      author: authors[2]._id,
      genre: 'Science Fiction',
      publishedYear: 1951,
      description: 'A saga about the fall and rise of a galactic empire',
      price: 11.99,
      inStock: true,
      reviews: [
        { reviewer: 'Grace', rating: 4, comment: 'Epic in scope' },
        { reviewer: 'Hank', rating: 3, comment: 'Dense but rewarding' }
      ]
    },
    {
      title: 'Murder on the Orient Express',
      author: authors[3]._id,
      genre: 'Mystery',
      publishedYear: 1934,
      description: 'A detective investigates a murder on a luxury train',
      price: 10.99,
      inStock: false,
      reviews: [
        { reviewer: 'Iris', rating: 5, comment: 'The twist is unforgettable' },
        { reviewer: 'Jack', rating: 4, comment: 'Classic whodunit' }
      ]
    },
    {
      title: 'Outliers',
      author: authors[4]._id,
      genre: 'Non-Fiction',
      publishedYear: 2008,
      description: 'An examination of factors that contribute to high levels of success',
      price: 15.99,
      inStock: true,
      reviews: [
        { reviewer: 'Karen', rating: 4, comment: 'Fascinating perspective on success' },
        { reviewer: 'Leo', rating: 3, comment: 'Some claims feel oversimplified' }
      ]
    }
  ]);

  console.log('Database seeded successfully');
  await mongoose.disconnect();
}

seed().catch((err) => {
  console.error('Seed error:', err);
  process.exit(1);
});

What does the seed script do?

  • It connects directly to your Atlas cluster using the same MONGODB_URI from .env.
  • It clears any existing authors and books so you start fresh every time you run it.
  • It uses insertMany to batch-insert all documents efficiently.
  • George Orwell has 2 books, making him the top author by count. This gives you predictable data to verify against.
  • Save seed.js.
  • Run the seed script by running this command:
npm run seed
  • You should see:
Connected to MongoDB Atlas
Database seeded successfully

Seed script failing?

  • If you see a connection error, make sure your server is NOT already running in another terminal. Both the server and seed script try to connect to the same cluster, which is fine, but check your MONGODB_URI in .env is correct.
  • If you see a duplicate key error on the author name field, the script may have partially run before. Run it again. The deleteMany({}) calls at the top clear old data before reinserting.

Help me debug my seed script error.

Now test both aggregation endpoints. Start your server first, then call the analytics routes.

  • Start your server by running:
npm start
  • Open a second terminal and test the ratings-by-genre endpoint by running:
curl http://localhost:3000/api/analytics/ratings-by-genre

You should see a JSON array with genres sorted by their average review rating. Fantasy should appear near the top with an average around 4.67.

  • Test the top-authors endpoint by running:
curl http://localhost:3000/api/analytics/top-authors

You should see George Orwell at the top with a bookCount of 2. All other authors should have a count of 1.

Getting an empty array back?

  • Make sure you ran npm run seed successfully before starting the server. The aggregation pipelines need data to process.
  • Check that your MONGODB_URI includes /bookstore as the database name. Without it, the seed script and server might be using different databases.

Help me debug why my aggregation endpoint returns an empty array.

✔️ Awesome, I've got everything!

Great. Make sure all files are saved before moving on.

ⓧ I'd like to double check the full code

import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import authorRoutes from './routes/authors.js';
import bookRoutes from './routes/books.js';
import analyticsRoutes from './routes/analytics.js';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.use('/api/authors', authorRoutes);
app.use('/api/books', bookRoutes);
app.use('/api/analytics', analyticsRoutes);

mongoose.connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB Atlas');
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err.message);
    process.exit(1);
  });
import { Router } from 'express';
import Book from '../models/Book.js';

const router = Router();

// Average rating by genre
router.get('/ratings-by-genre', async (req, res) => {
  try {
    const results = await Book.aggregate([
      { $unwind: '$reviews' },
      {
        $group: {
          _id: '$genre',
          averageRating: { $avg: '$reviews.rating' },
          totalReviews: { $sum: 1 }
        }
      },
      { $sort: { averageRating: -1 } },
      {
        $project: {
          genre: '$_id',
          averageRating: { $round: ['$averageRating', 2] },
          totalReviews: 1,
          _id: 0
        }
      }
    ]);
    res.json(results);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Top authors by book count
router.get('/top-authors', async (req, res) => {
  try {
    const results = await Book.aggregate([
      {
        $group: {
          _id: '$author',
          bookCount: { $sum: 1 }
        }
      },
      { $sort: { bookCount: -1 } },
      { $limit: 5 },
      {
        $lookup: {
          from: 'authors',
          localField: '_id',
          foreignField: '_id',
          as: 'authorInfo'
        }
      },
      { $unwind: '$authorInfo' },
      {
        $project: {
          authorName: '$authorInfo.name',
          bookCount: 1,
          _id: 0
        }
      }
    ]);
    res.json(results);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

export default router;
import 'dotenv/config';
import mongoose from 'mongoose';
import Author from './models/Author.js';
import Book from './models/Book.js';

async function seed() {
  await mongoose.connect(process.env.MONGODB_URI);
  console.log('Connected to MongoDB Atlas');

  // Clear existing data
  await Author.deleteMany({});
  await Book.deleteMany({});

  // Create authors
  const authors = await Author.insertMany([
    { name: 'George Orwell', biography: 'English novelist and essayist', birthYear: 1903 },
    { name: 'J.K. Rowling', biography: 'British author of the Harry Potter series', birthYear: 1965 },
    { name: 'Isaac Asimov', biography: 'American writer and professor of biochemistry', birthYear: 1920 },
    { name: 'Agatha Christie', biography: 'English writer of detective novels', birthYear: 1890 },
    { name: 'Malcolm Gladwell', biography: 'Canadian journalist and author', birthYear: 1963 }
  ]);

  // Create books with embedded reviews
  await Book.insertMany([
    {
      title: '1984',
      author: authors[0]._id,
      genre: 'Fiction',
      publishedYear: 1949,
      description: 'A dystopian novel set in a totalitarian society',
      price: 12.99,
      inStock: true,
      reviews: [
        { reviewer: 'Alice', rating: 5, comment: 'A masterpiece of political fiction' },
        { reviewer: 'Bob', rating: 4, comment: 'Chilling and thought-provoking' }
      ]
    },
    {
      title: 'Animal Farm',
      author: authors[0]._id,
      genre: 'Fiction',
      publishedYear: 1945,
      description: 'An allegorical novella about a group of farm animals',
      price: 9.99,
      inStock: true,
      reviews: [
        { reviewer: 'Charlie', rating: 5, comment: 'Brilliant satire' }
      ]
    },
    {
      title: 'Harry Potter and the Philosophers Stone',
      author: authors[1]._id,
      genre: 'Fantasy',
      publishedYear: 1997,
      description: 'A young wizard discovers his magical heritage',
      price: 14.99,
      inStock: true,
      reviews: [
        { reviewer: 'Diana', rating: 5, comment: 'Magical world-building' },
        { reviewer: 'Eve', rating: 4, comment: 'Great for all ages' },
        { reviewer: 'Frank', rating: 5, comment: 'Captivating from start to finish' }
      ]
    },
    {
      title: 'Foundation',
      author: authors[2]._id,
      genre: 'Science Fiction',
      publishedYear: 1951,
      description: 'A saga about the fall and rise of a galactic empire',
      price: 11.99,
      inStock: true,
      reviews: [
        { reviewer: 'Grace', rating: 4, comment: 'Epic in scope' },
        { reviewer: 'Hank', rating: 3, comment: 'Dense but rewarding' }
      ]
    },
    {
      title: 'Murder on the Orient Express',
      author: authors[3]._id,
      genre: 'Mystery',
      publishedYear: 1934,
      description: 'A detective investigates a murder on a luxury train',
      price: 10.99,
      inStock: false,
      reviews: [
        { reviewer: 'Iris', rating: 5, comment: 'The twist is unforgettable' },
        { reviewer: 'Jack', rating: 4, comment: 'Classic whodunit' }
      ]
    },
    {
      title: 'Outliers',
      author: authors[4]._id,
      genre: 'Non-Fiction',
      publishedYear: 2008,
      description: 'An examination of factors that contribute to high levels of success',
      price: 15.99,
      inStock: true,
      reviews: [
        { reviewer: 'Karen', rating: 4, comment: 'Fascinating perspective on success' },
        { reviewer: 'Leo', rating: 3, comment: 'Some claims feel oversimplified' }
      ]
    }
  ]);

  console.log('Database seeded successfully');
  await mongoose.disconnect();
}

seed().catch((err) => {
  console.error('Seed error:', err);
  process.exit(1);
});

Your aggregation endpoints are live and computing analytics across your entire collection. Next up, you'll add indexing and text search to make your queries faster and more powerful.

Add Indexing and Text Search

Your bookstore API has full CRUD and analytics endpoints. The next question to answer is: how fast can MongoDB find the right documents as your collection grows from 6 books to 6,000?

Indexes solve this. You already defined indexes in your Book schema during the modeling step. Now you'll test them, prove they work, and see the performance difference with your own eyes using MongoDB's explain() tool.

In this step, get ready to:
  • Test the text search endpoint to find books by keyword.
  • Run explain() to prove the compound index uses IXSCAN instead of COLLSCAN.
  • List all indexes to confirm they're registered on your cluster.
Test the text search endpoint

The text index on title and description is already defined in models/Book.js. The /api/books/search endpoint is wired up in routes/books.js. Time to prove it returns results.

  • Make sure your server is running. If it's stopped, start it by running:
node server.js
  • Test the text search endpoint by running this command in a new terminal tab:
curl http://localhost:3000/api/books/search?q=dystopian
  • You should see a JSON response containing the book "1984" because its description includes the word "dystopian".
  • Try another search term to verify broader matching:
curl http://localhost:3000/api/books/search?q=wizard
  • You should see "Harry Potter and the Philosophers Stone" in the response because its description mentions "wizard".

What is a text index?

A text index lets MongoDB search string fields for keywords without scanning every document. It tokenizes the text into individual words and builds a lookup structure that maps words to the documents that contain them.

Your Book schema defines it with bookSchema.index({ title: 'text', description: 'text' }). This tells Mongoose to create a single text index spanning both fields.

Getting an empty array back?

Make sure you seeded the database with node seed.js in the previous step. The search endpoint needs actual book documents to search against.

Also check that your search term appears in either the title or description fields of your seed data.

help me debug why my text search returns no results

Run explain() to compare IXSCAN vs COLLSCAN

MongoDB's explain() method reveals how a query actually executed. It shows whether MongoDB scanned an index (IXSCAN) or every single document (COLLSCAN).

  • Run the following command to execute a filtered query with explain and see the execution plan:
node --input-type=module -e "
import 'dotenv/config';
import mongoose from 'mongoose';
import Book from './models/Book.js';

await mongoose.connect(process.env.MONGODB_URI);

// Run a query that matches our compound index on genre + publishedYear
const explained = await Book.find({ genre: 'Fiction', publishedYear: { \$lte: 2000 } }).explain('executionStats');

console.log('Winning plan stage:', JSON.stringify(explained[0]?.queryPlanner?.winningPlan, null, 2));
console.log('Execution stats:');
console.log('  totalKeysExamined:', explained[0]?.executionStats?.totalKeysExamined);
console.log('  totalDocsExamined:', explained[0]?.executionStats?.totalDocsExamined);
console.log('  nReturned:', explained[0]?.executionStats?.nReturned);

await mongoose.disconnect();
"
  • Look for IXSCAN in the winning plan output. This confirms MongoDB used your compound index { genre: 1, publishedYear: -1 } instead of scanning all documents.
  • The totalKeysExamined value should be close to nReturned. This means MongoDB only looked at index entries that matched, not every document in the collection.

What does IXSCAN vs COLLSCAN mean?

COLLSCAN means MongoDB had to scan every document in the collection to find matches. With 6 documents this is fine, but with 60,000 it becomes a serious performance problem.

IXSCAN means MongoDB jumped directly to matching entries using the index. It only examined the documents that actually matched the query criteria. The totalKeysExamined field shows exactly how many index entries were checked.

Seeing COLLSCAN instead of IXSCAN?

With very small collections (under ~10 documents), MongoDB sometimes decides a collection scan is faster than using the index. This is expected behaviour for tiny datasets.

If you want to force index usage to confirm it exists, re-run the seed script a few times to add more documents, or check the index list in the next substep.

help me understand why MongoDB chose COLLSCAN over IXSCAN

Verify indexes are active

The final proof is listing all indexes on the books collection and confirming both the text index and compound index are registered.

  • Run this command to list all indexes on the books collection:
node --input-type=module -e "
import 'dotenv/config';
import mongoose from 'mongoose';
import Book from './models/Book.js';

await mongoose.connect(process.env.MONGODB_URI);

// List all indexes on the books collection
const indexes = await Book.collection.getIndexes();
console.log('Indexes on books collection:');
console.log(JSON.stringify(indexes, null, 2));

await mongoose.disconnect();
"
  • You should see at least three indexes in the output:
  • _id_ (the default index MongoDB creates on every collection).
  • A text index with keys including title and description set to "text".
  • A compound index with { genre: 1, publishedYear: -1 }.

Why do indexes matter in production?

With 6 seed documents, queries are instant regardless of indexing. But in a production bookstore with thousands of titles, a query like "find all Fiction books published before 2000" without an index would scan every single document.

Your compound index on { genre: 1, publishedYear: -1 } lets MongoDB jump straight to Fiction books sorted by year. The text index lets the search endpoint match keywords without reading every title and description field.

Only seeing the _id index?

Mongoose creates indexes when the model is first loaded and a connection is established. Make sure your server has started successfully at least once since you added the index definitions to models/Book.js.

Try stopping and restarting your server with node server.js. Mongoose will sync indexes on connection.

help me figure out why my Mongoose indexes aren't showing up

✔️ Awesome, I've got everything!

Great. You've confirmed your text index powers the search endpoint and your compound index enables efficient filtered queries.

ⓧ I'd like to double check the full code

Here's models/Book.js with both index definitions at the bottom:

import mongoose from 'mongoose';

const { Schema } = mongoose;

const reviewSchema = new Schema({
  reviewer: { type: String, required: true },
  rating: { type: Number, required: true, min: 1, max: 5 },
  comment: { type: String, default: '' },
  date: { type: Date, default: Date.now }
}, { _id: true });

const bookSchema = new Schema({
  title: {
    type: String,
    required: [true, 'Book title is required'],
    trim: true
  },
  author: {
    type: Schema.Types.ObjectId,
    ref: 'Author',
    required: [true, 'Author is required']
  },
  genre: {
    type: String,
    required: true,
    enum: ['Fiction', 'Non-Fiction', 'Science Fiction', 'Fantasy', 'Mystery', 'Biography', 'History', 'Self-Help']
  },
  publishedYear: {
    type: Number
  },
  description: {
    type: String,
    default: ''
  },
  price: {
    type: Number,
    required: true,
    min: 0
  },
  inStock: {
    type: Boolean,
    default: true
  },
  reviews: [reviewSchema]
}, {
  timestamps: true
});

bookSchema.index({ title: 'text', description: 'text' });
bookSchema.index({ genre: 1, publishedYear: -1 });

const Book = mongoose.model('Book', bookSchema);

export default Book;

And here's the search route in routes/books.js (the relevant section):

// Search books by text
router.get('/search', async (req, res) => {
  try {
    const { q } = req.query;
    if (!q) return res.status(400).json({ error: 'Query parameter q is required' });
    const books = await Book.find({ $text: { $search: q } })
      .populate('author', 'name');
    res.json(books);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

Your indexes are confirmed active and powering efficient queries. Next up, you'll extend the API with dynamic filtering and pagination in the Secret Mission.

Secret mission

Add Filtering and Pagination

Your bookstore API returns every book at once. Real APIs let users filter by genre, price range, or rating, and paginate through large result sets. Can you upgrade the GET /api/books endpoint to handle dynamic filtering and skip/limit pagination?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses MongoDB Atlas's free tier, so there are no ongoing costs.

Resources you used:

  • MongoDB Atlas free-tier cluster with a bookstore database.
  • Local bookstore-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the API further.

  • Your Atlas free-tier cluster costs $0 and stays running indefinitely.
  • Atlas will auto-pause your cluster after 30 days of zero connections. You can resume it anytime from the Atlas dashboard.
  • Your local bookstore-api folder remains on your Desktop for future use.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and cluster stay intact.

  • Stop the Node.js server by pressing Ctrl+C in the terminal where it is running.
  • Your Atlas cluster will auto-pause after 30 days with no connections. No manual action required.
  • When you return, run node server.js from your bookstore-api folder to pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your data.

  • Delete your Atlas cluster:
  • Delete your local project folder:
cd ~/Desktop
rm -rf bookstore-api
  • On Windows, run:
cd ~\Desktop
rmdir /s /q bookstore-api

Nice Work!

Nice Work!

Nice work! You just built a complete bookstore inventory API backed by MongoDB Atlas, from schema design all the way through aggregation pipelines and index optimization.

You've learned how to:

  • Design Mongoose schemas using both embedded documents (reviews) and references (authors), and build a full CRUD REST API with populate to resolve cross-collection relationships.
  • Write multi-stage aggregation pipelines that compute analytics like average ratings by genre and top authors by book count using $unwind, $group, $lookup, and $project.
  • Create text indexes and compound indexes on your collections and prove their performance impact with explain() output comparing COLLSCAN vs IXSCAN.
  • Secret Mission: Added dynamic filtering and pagination to handle real-world query patterns with query parameters, conditional filter construction, and skip/limit pagination.

Ready to quiz yourself?