Build a Bookstore REST API
Build a CRUD REST API with Express and MongoDB Atlas using Mongoose.
Introduction
30 Second Summary
Think about how you browse an online bookstore. Every time you search for a book, add one to your cart, or check what's in stock, you're talking to a backend API that manages all that data behind the scenes.
In this project, you will build a REST API from scratch that creates, reads, updates, and deletes books in a cloud database. You'll use Express for routing, MongoDB Atlas for storage, and Mongoose for data validation.
What You'll Build
A complete Bookstore Inventory API with five endpoints, a Mongoose schema with validation, and centralized error handling middleware.
By the end of this project, you'll have:
- An Express server with structured routes and middleware that handles requests and errors gracefully.
- A Mongoose model with schema validation for book data including title, author, genre, published year, and pages.
- Five working CRUD endpoints tested with Thunder Client inside VS Code.
- Secret Mission: Add query filtering so users can search books by author, genre, or publication year.
Are there any prerequisites?
You should have basic JavaScript knowledge (variables, functions, objects). No prior experience with Express, MongoDB, or APIs is needed.
Before We Start
Every web application needs a way to store and retrieve data. A REST API is the standard pattern for exposing that data over HTTP, so any frontend or mobile app can communicate with your backend.
In this step, you'll set up the tools and accounts you need to build the Bookstore API. This includes a cloud database on MongoDB Atlas, a project folder with all dependencies installed, and a lightweight API testing tool inside VS Code.
In this step, get ready to:
- Create a MongoDB Atlas account and deploy a free cluster.
- Create the project folder and install all dependencies.
- Install Thunder Client and verify your setup.
Create a MongoDB Atlas account and deploy a free cluster
MongoDB Atlas is a cloud-hosted database service. The free tier gives you 512MB of storage that never expires, which is perfect for development.
- Open your browser and go to the MongoDB Atlas registration page:
- https://www.mongodb.com/cloud/atlas/register
- Create a free account using your email or sign in with Google.
- Complete any welcome prompts that appear.
What is MongoDB Atlas?
MongoDB Atlas is a Database-as-a-Service (DBaaS). Instead of installing and running a database on your own computer, Atlas hosts it in the cloud for you. The free tier (M0) provides a shared cluster with 512MB of storage.
- On the Project Overview page, click the Create button to start deploying a new cluster.
- Select the M0 (Free) option.
- Choose any available cloud provider (AWS, GCP, or Azure) and region.
- In the Name field, type bookstore-cluster.
- Click Create to deploy the cluster. It takes less than 15 seconds to provision.
The Security Quickstart wizard appears after your cluster is created.
- Create a database user by entering a Username and Password. Write these down somewhere safe.
- Click Create Database User.
- Click Add My Current IP Address to allow connections from your computer.
- Click Finish and Close, then Go to Overview.
Now you need to copy the connection string that your application will use to connect to the database.
- On the cluster overview page, click Connect next to your cluster name.
- Select Drivers under the connection method options.
- Copy the connection string shown. It looks something like mongodb+srv://<username>:<password>@bookstore-cluster.xxxxx.mongodb.net/.
- Paste it somewhere temporary (a text file or notes app). You will use it shortly.
Replace the password placeholder
The connection string contains a <db_password> placeholder. Replace it with the password you created for your database user. This is not your Atlas login password.
Can't find the Connect button?
Your cluster may still be provisioning. Wait a few seconds and refresh the page. The Connect button appears once the cluster status shows as active.
help me find the Connect button on my MongoDB Atlas cluster
Create the project and install dependencies
Your API needs a project folder with a few key packages. Express handles HTTP routing, Mongoose connects to MongoDB, and dotenv loads environment variables from a file.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
- 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 this command do?
The npm init -y command creates a package.json file with default settings. This file tracks your project's name, version, and dependencies. The -y flag accepts all defaults so you skip the interactive prompts.
- Install the three main packages your project needs by running:
npm install express@5.2.1 mongoose@9.7.1 dotenv@17.4.2
- Install nodemon as a development dependency by running:
npm install --save-dev nodemon@3.1.14
What is nodemon?
Nodemon watches your files and automatically restarts the server whenever you save a change. It is a development tool only, which is why you install it with --save-dev.
Now you need to update package.json with custom scripts to run your server.
- Open package.json in VS Code by clicking on it in the Explorer sidebar.
- Find the "scripts" section. It currently looks like this:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}
- Replace the entire "scripts" section with:
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
}
What do these scripts do?
The dev script runs your server with nodemon, which auto-restarts on file changes. The start script runs the server with plain Node.js, which is what you would use in production.
- While you're in package.json, update the "description" field to:
"description": "Bookstore REST API with Express and MongoDB"
- Save package.json.
Seeing red squiggly lines in package.json?
Make sure every key-value pair except the last one in each object ends with a comma. JSON is strict about trailing commas and missing commas.
help me fix JSON syntax errors in my package.json
Install Thunder Client and verify your setup
Thunder Client is a lightweight REST API testing tool that lives right inside VS Code. It lets you send HTTP requests without leaving your editor.
- In VS Code, open the Extensions panel by pressing Ctrl+Shift+X (Windows) or Cmd+Shift+X (macOS).
- Type Thunder Client in the search bar.
- Click Install on the Thunder Client extension by Ranga Vadhineni.
You should see a lightning bolt icon appear in the left sidebar. That confirms Thunder Client is installed.
Next, create the environment configuration files your project needs.
- Create a new file called .env in the root of your bookstore-api folder. In VS Code, click the new file icon in the Explorer sidebar and name it .env.
- Paste the following into your .env file:
MONGODB_URI=your-connection-string-here
PORT=3000
- Replace your-connection-string-here with the connection string you copied from MongoDB Atlas earlier. Make sure you have already replaced the <db_password> placeholder with your actual database user password.
- Add /bookstore before the ? in your connection string to specify the database name. Your final string should look like: mongodb+srv://username:password@bookstore-cluster.xxxxx.mongodb.net/bookstore?retryWrites=true&w=majority.
- Save .env.
Why use a .env file?
The .env file keeps sensitive values like database credentials out of your source code. The dotenv package loads these values into your app at runtime, so you never hardcode secrets directly in your JavaScript files.
- Create another new file called .gitignore in the root of your bookstore-api folder.
- Paste the following into .gitignore:
node_modules
.env
- Save .gitignore.
What does .gitignore do?
The .gitignore file tells Git which files to exclude from version control. You exclude node_modules because it contains thousands of dependency files that can be reinstalled. You exclude .env because it contains your database password.
Finally, confirm that Node.js is working correctly.
- Verify your Node.js installation by running:
node --version
You should see a version number like v20.x.x or v22.x.x. Any version 18 or higher works for this project.
Seeing a version lower than 18?
Express 5 requires Node.js 18 or higher. Visit https://nodejs.org to download the latest LTS version. After installing, close and reopen your terminal, then run node --version again.
help me upgrade Node.js to version 18 or higher
- Confirm your project files exist by running:
ls
You should see package.json, node_modules, .env, and .gitignore in the output.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here is what each file should contain at this point:
MONGODB_URI=mongodb+srv://your-username:your-password@bookstore-cluster.xxxxx.mongodb.net/bookstore?retryWrites=true&w=majority
PORT=3000
node_modules
.env
{
"name": "bookstore-api",
"version": "1.0.0",
"description": "Bookstore REST API with Express and MongoDB",
"main": "server.js",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
},
"dependencies": {
"dotenv": "17.4.2",
"express": "5.2.1",
"mongoose": "9.7.1"
},
"devDependencies": {
"nodemon": "3.1.14"
}
}
Your development environment is ready. Next up, you'll create the Express server and connect it to your MongoDB database.
Create the Express Server and Connect to MongoDB
Your development environment is ready with all the packages installed and your database waiting in the cloud.
Now you need a way for your code to actually talk to that database and respond to incoming requests. In this step, you will create the main server file that boots up Express, connects to MongoDB, and listens for traffic.
In this step, get ready to:
- Create the server entry point with Express and Mongoose configuration.
- Start the server and confirm a successful database connection.
- Send your first API request using Thunder Client.
Set up the entry point with Express and database connection
Every Express application starts with a single file that imports your packages, configures middleware, and defines how the server starts. This file is your application's control center.
- Create a file called server.js in your bookstore-api folder.
- Add the following code to configure Express and define a root route:
const dotenv = require('dotenv');
dotenv.config();
const express = require('express');
const mongoose = require('mongoose');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
What does this code do?
- dotenv.config() loads the variables from your .env file (like your MongoDB connection string) into process.env.
- express.json() is middleware that parses incoming JSON request bodies so your routes can read them.
- The root GET route at / gives you a quick way to confirm the server is alive.
- PORT reads from your .env file first, and falls back to 3000 if nothing is set.
- You won't see a change yet. The next chunk adds the database connection that brings the server online.
- Add the following code below your root route in server.js to connect to MongoDB and start the server:
async function startServer() {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log('Connected to MongoDB');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
} catch (error) {
console.error('Failed to connect to MongoDB:', error.message);
process.exit(1);
}
}
startServer();
What does this code do?
- startServer() is an async function that first tries to connect to your MongoDB Atlas cluster using the URI from .env.
- Only after the database connection succeeds does the server begin listening on the port. This prevents requests from arriving before the database is ready.
- If the connection fails, the error is logged and process.exit(1) stops the process so you know something is wrong immediately.
- Save server.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
const dotenv = require('dotenv');
dotenv.config();
const express = require('express');
const mongoose = require('mongoose');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
async function startServer() {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log('Connected to MongoDB');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
} catch (error) {
console.error('Failed to connect to MongoDB:', error.message);
process.exit(1);
}
}
startServer();
Test the server
With the server file complete, you can start it and confirm that both Express and the MongoDB connection are working.
- Start the server with nodemon by running this command in your terminal:
npm run dev
- Check your terminal output. You should see two lines:
Connected to MongoDB
Server running on port 3000
What is nodemon?
Nodemon watches your files for changes and automatically restarts the server whenever you save. You installed it as a dev dependency in the previous step, and the dev script in package.json runs it for you.
Not seeing 'Connected to MongoDB'?
- Check that your MONGODB_URI in .env has your actual database username and password filled in (not the placeholder text).
- Make sure your current IP address is added to the MongoDB Atlas IP Access List. If your IP changed since setup, add the new one in the Atlas dashboard under **Network Access**.
- Confirm dotenv.config() is called before mongoose.connect() in your file. If dotenv loads after the connection attempt, the URI will be undefined.
Help me debug my MongoDB connection error
Now confirm the server responds to HTTP requests using Thunder Client.
- Click the Thunder Client icon in the Activity Bar on the left side of VS Code.
- Click **New Request** in the Thunder Client sidebar.
- Make sure the method is set to **GET**.
- Enter http://localhost:3000 in the URL field.
- Click **Send**.
You should see a JSON response in the Response panel:
{
"message": "Welcome to the Bookstore API"
}
Seeing 'connect ECONNREFUSED'?
- Make sure your server is still running in the terminal. If you see an error there, fix it and let nodemon restart.
- Confirm you typed http://localhost:3000 exactly. Missing the http:// prefix can cause connection failures.
Help me fix my Thunder Client connection error
Your server is running and connected to the cloud database. Next up, you will define a Mongoose schema that describes exactly what a book looks like in your database.
Define the Book Model with Mongoose
Your server is connected to MongoDB, but right now it has no idea what a "book" looks like. Without a defined structure, you could accidentally store a book with a missing title or a page count that is actually a sentence.
In this step, you will create a Mongoose model that defines the exact shape of a book document. This model acts as a gatekeeper, rejecting any data that does not match your rules before it reaches the database.
In this step, get ready to:
- Create a Book schema with required fields and validation.
- Test the model by saving a book to your MongoDB Atlas database.
- Confirm the schema rejects invalid data.
Create the models directory and Book schema
A schema defines the fields, types, and validation rules for documents in a collection. You will create a dedicated folder for models to keep your project organized.
- Create a models folder and a Book.js file inside it by running these commands in your terminal:
mkdir models
touch models/Book.js
- Confirm the file exists by running:
ls models
You should see Book.js in the output.
- Open models/Book.js in the VS Code Explorer sidebar and add the following code:
const mongoose = require('mongoose');
const bookSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Book title is required'],
trim: true
},
author: {
type: String,
required: [true, 'Author name is required'],
trim: true
},
genre: {
type: String,
trim: true
},
publishedYear: {
type: Number
},
pages: {
type: Number
}
}, {
timestamps: true
});
module.exports = mongoose.model('Book', bookSchema);
What does this code do?
- title and author are marked as required. Mongoose will reject any book that is missing either field and return the custom error message you provided.
- trim: true automatically removes leading and trailing whitespace from string fields before saving.
- timestamps: true adds createdAt and updatedAt fields automatically every time a document is created or modified.
- mongoose.model('Book', bookSchema) compiles the schema into a model. This model is what you use to create, read, update, and delete documents.
- Save models/Book.js.
Seeing a syntax error in your terminal?
- Make sure every opening brace { has a matching closing brace }. The schema definition has several nested objects.
- Check that the second argument to new mongoose.Schema() (the options object with timestamps) is separated from the first by a comma.
Help me debug my Book.js schema file
Test the model by creating a book via the server
The schema file is ready, but you have not used it anywhere yet. You will add a temporary test route to server.js that creates a book and saves it to MongoDB. Once you confirm it works, you will remove the test route.
- In server.js, find the line that says:
const app = express();
- Add the following import directly above that line:
const Book = require('./models/Book');
- Now find the root GET route that starts with app.get('/'. Add the following temporary test route directly below the closing of that route:
// Temporary test route - remove after testing
app.post('/test-book', async (req, res) => {
try {
const book = await Book.create({
title: 'The Hobbit',
author: 'J.R.R. Tolkien',
genre: 'Fantasy',
publishedYear: 1937,
pages: 310
});
res.status(201).json(book);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
What does this test route do?
- Book.create() validates the data against your schema and then inserts a new document into the books collection in MongoDB.
- If validation passes, it returns the saved document with a 201 status code (meaning "created").
- If validation fails (e.g., missing title), it catches the error and returns a 400 status code with the error message.
- Save server.js. Nodemon should automatically restart the server.
- In VS Code, click the Thunder Client icon in the left sidebar.
- Click **New Request**.
- Change the method dropdown from **GET** to **POST**.
- Enter http://localhost:3000/test-book in the URL field.
- Click **Send**.
You should see a JSON response with your book data, including an _id field, createdAt, and updatedAt timestamps. The status code at the top of the response panel should say 201 Created.
What is an _id?
MongoDB automatically generates a unique _id for every document. This is how you will reference individual books later when building GET, PUT, and DELETE routes.
- Now verify the book exists in your cloud database. Open your browser and go to MongoDB Atlas.
- In the Atlas sidebar, click **Data Explorer** under the **Database** heading.
- Select the bookstore database from the left panel.
- Click the books collection.
You should see your "The Hobbit" document listed with all its fields.
Don't see the bookstore database?
- Click the refresh icon in the Atlas Data Explorer. New databases only appear after data has been written.
- Check your Thunder Client response. If you got an error instead of the book JSON, the document was not created.
- Make sure the MONGODB_URI in your .env file includes /bookstore before the ? in the connection string. This tells Mongoose which database to use.
Help me troubleshoot my MongoDB connection
Now test that the schema validation actually works. This confirms that required fields are enforced.
- In Thunder Client, create a new POST request to http://localhost:3000/test-book.
- This time, click the **Body** tab below the URL field.
- Select **JSON** as the body type.
- Paste this JSON body that is deliberately missing the required title field:
{
"author": "Test Author",
"genre": "Fiction"
}
- Click **Send**.
You should see a 400 status code and an error message containing "Book title is required". This proves your schema is catching invalid data before it reaches the database.
Why test with invalid data?
Schema validation is your first line of defense against bad data. By testing it now, you know that later, when real users send requests to your API, Mongoose will reject incomplete or malformed documents before they pollute your database.
- Now remove the temporary test route and the Book import from server.js. Delete the const Book = require('./models/Book'); line and the entire app.post('/test-book', ...) block.
- Save server.js.
Why remove the test route?
The test route was only for confirming your model works. In the next step, you will build proper routes in a dedicated file with full CRUD capabilities. Keeping temporary routes clutters your server and creates confusion about which endpoints are real.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is what models/Book.js should look like:
const mongoose = require('mongoose');
const bookSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Book title is required'],
trim: true
},
author: {
type: String,
required: [true, 'Author name is required'],
trim: true
},
genre: {
type: String,
trim: true
},
publishedYear: {
type: Number
},
pages: {
type: Number
}
}, {
timestamps: true
});
module.exports = mongoose.model('Book', bookSchema);
Here is what server.js should look like after removing the test route:
const dotenv = require('dotenv');
dotenv.config();
const express = require('express');
const mongoose = require('mongoose');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
async function startServer() {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log('Connected to MongoDB');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
} catch (error) {
console.error('Failed to connect to MongoDB:', error.message);
process.exit(1);
}
}
startServer();
Your data model is locked in. Next up, you will build the actual API routes that let clients create, read, update, and delete books through proper endpoints.
Build the CRUD Routes
Your Book model is ready to store data with schema validation. Now you need a way for users to actually interact with that data.
In this step, you will build five API endpoints that let anyone create, read, update, and delete books from your database. These endpoints follow the REST pattern, where each URL and HTTP method combination maps to a specific operation.
In this step, get ready to:
- Create a routes file with GET and POST endpoints.
- Add PUT and DELETE endpoints to complete all CRUD operations.
- Test all five endpoints with Thunder Client to confirm they work.
Create the routes file with GET and POST endpoints
Express organizes endpoints into separate route files so your server.js stays clean. You will create a dedicated file for all book-related routes.
- Create a routes folder inside your bookstore-api project folder by running this command:
mkdir routes
- Create a new file called bookRoutes.js inside the routes folder. You can do this in VS Code by right-clicking the routes folder in the Explorer sidebar and selecting New File.
- Add the file setup and the first GET route by copying this code into routes/bookRoutes.js:
const express = require('express');
const router = express.Router();
const Book = require('../models/Book');
// GET all books
router.get('/books', async (req, res, next) => {
try {
const books = await Book.find();
res.json(books);
} catch (error) {
next(error);
}
});
What does this code do?
This sets up the routes file and creates your first endpoint.
- express.Router() creates a mini-app that holds routes. You will mount this on your main server later.
- Book.find() fetches every book document from MongoDB and returns them as an array.
- next(error) passes any database errors to Express's error handling system instead of crashing the server.
- Add the GET-by-ID route below the first route in routes/bookRoutes.js:
// GET single book by ID
router.get('/books/:id', async (req, res, next) => {
try {
const book = await Book.findById(req.params.id);
if (!book) {
return res.status(404).json({ error: 'Book not found' });
}
res.json(book);
} catch (error) {
next(error);
}
});
What does this code do?
This endpoint retrieves a single book by its unique database ID.
- :id in the URL is a route parameter. Express captures whatever the user puts there and stores it in req.params.id.
- If findById() returns null, the book does not exist. The route responds with a 404 status and a clear error message.
- Add the POST route below the GET-by-ID route in routes/bookRoutes.js:
// POST create a new book
router.post('/books', async (req, res, next) => {
try {
const book = await Book.create(req.body);
res.status(201).json(book);
} catch (error) {
next(error);
}
});
What does this code do?
This endpoint creates a new book in the database.
- req.body contains the JSON data the user sends in their request. The express.json() middleware you set up in server.js parses this automatically.
- Book.create() validates the data against your schema (checking required fields) and saves it to MongoDB.
- Status 201 means "Created" and tells the client that a new resource was successfully made.
- Save routes/bookRoutes.js.
Seeing a syntax error in your terminal?
Check that every opening brace { has a matching closing brace }, and every opening parenthesis ( has a matching ). Route handlers have multiple nested layers. Also confirm that the require('../models/Book') path uses ../ (go up one folder) since Book.js lives in models/, not routes/.
Help me fix a syntax error in my bookRoutes.js file
Add PUT and DELETE endpoints
The GET and POST routes handle reading and creating. Now you need PUT (update) and DELETE (remove) to complete the full set of CRUD operations.
- Add the PUT route below the POST route in routes/bookRoutes.js:
// PUT update a book
router.put('/books/:id', async (req, res, next) => {
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 (error) {
next(error);
}
});
What does this code do?
This endpoint updates an existing book with new data.
- findByIdAndUpdate() finds the book and applies the changes in one database call.
- { new: true } tells Mongoose to return the updated document. Without it, you would get the old version back.
- { runValidators: true } re-checks schema rules (like required fields) on the updated data. Mongoose skips validation on updates by default.
- Add the DELETE route and the module export below the PUT route in routes/bookRoutes.js:
// DELETE a book
router.delete('/books/:id', async (req, res, next) => {
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 successfully' });
} catch (error) {
next(error);
}
});
module.exports = router;
What does this code do?
This endpoint permanently removes a book from the database.
- findByIdAndDelete() finds and removes the document in one step. If no book matches the ID, it returns null and you respond with a 404.
- module.exports = router makes this router available to import in server.js.
- Save routes/bookRoutes.js.
Now you need to connect this router to your main server. Your routes file is ready, but Express does not know about it yet.
- In server.js, find this line:
const mongoose = require('mongoose');
- Add this new line directly below it:
const bookRouter = require('./routes/bookRoutes');
- Next, find the root GET route in server.js:
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
- Add this line directly below the closing }); of the root route:
app.use('/api', bookRouter);
What does app.use('/api', bookRouter) do?
This mounts all routes from bookRoutes.js under the /api prefix. Since the routes file defines /books, the full URL becomes /api/books. This keeps your API endpoints organized under a common base path.
- Save server.js. Nodemon will automatically restart the server.
- Check your terminal. You should see Connected to MongoDB and Server running on port 3000 with no errors.
Server crashing after saving?
Check that the require path in server.js is './routes/bookRoutes' (starts with ./). Without the dot, Node.js looks for a package named routes instead of your local file. Also make sure you have module.exports = router at the bottom of bookRoutes.js.
Help me fix my server.js import error
✔️ Awesome, I've got everything!
Great. Double check you have saved both routes/bookRoutes.js and server.js.
ⓧ I'd like to double check the full code
Here is the complete routes/bookRoutes.js:
const express = require('express');
const router = express.Router();
const Book = require('../models/Book');
// GET all books
router.get('/books', async (req, res, next) => {
try {
const books = await Book.find();
res.json(books);
} catch (error) {
next(error);
}
});
// GET single book by ID
router.get('/books/:id', async (req, res, next) => {
try {
const book = await Book.findById(req.params.id);
if (!book) {
return res.status(404).json({ error: 'Book not found' });
}
res.json(book);
} catch (error) {
next(error);
}
});
// POST create a new book
router.post('/books', async (req, res, next) => {
try {
const book = await Book.create(req.body);
res.status(201).json(book);
} catch (error) {
next(error);
}
});
// PUT update a book
router.put('/books/:id', async (req, res, next) => {
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 (error) {
next(error);
}
});
// DELETE a book
router.delete('/books/:id', async (req, res, next) => {
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 successfully' });
} catch (error) {
next(error);
}
});
module.exports = router;
And here is the complete server.js:
const dotenv = require('dotenv');
dotenv.config();
const express = require('express');
const mongoose = require('mongoose');
const bookRouter = require('./routes/bookRoutes');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
app.use('/api', bookRouter);
async function startServer() {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log('Connected to MongoDB');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
} catch (error) {
console.error('Failed to connect to MongoDB:', error.message);
process.exit(1);
}
}
startServer();
Test all endpoints with Thunder Client
Your routes are wired up. Time to send real requests and confirm each endpoint works as expected.
- Click the Thunder Client icon in the VS Code Activity Bar (left sidebar) to open it.
- Click New Request.
- Set the method dropdown to POST.
- Enter http://localhost:3000/api/books as the URL.
- Click the Body tab, select JSON, and paste the following:
{
"title": "The Hobbit",
"author": "J.R.R. Tolkien",
"genre": "Fantasy",
"publishedYear": 1937,
"pages": 310
}
- Click Send.
You should see a response with status 201 Created and the book data including a generated _id field. Copy this _id value for the next tests.
- Send another POST request with a second book to have more data to work with:
{
"title": "1984",
"author": "George Orwell",
"genre": "Dystopian",
"publishedYear": 1949,
"pages": 328
}
- Change the method to GET and send a request to http://localhost:3000/api/books.
You should see an array containing both books you just created. The status will be 200 OK.
- Send a GET request to http://localhost:3000/api/books/ followed by the _id you copied earlier.
You should see just that single book returned with status 200 OK.
- Change the method to PUT and keep the same URL (with the _id).
- In the Body tab, paste this JSON to update the page count:
{
"pages": 315
}
- Click Send.
You should see the updated book with pages now showing 315 and status 200 OK.
- Change the method to DELETE and keep the same URL.
- Click Send.
You should see { "message": "Book deleted successfully" } with status 200 OK.
- Confirm the deletion worked by sending a GET request to http://localhost:3000/api/books. The deleted book should no longer appear in the list.
Does data persist across restarts?
Yes. Your books are stored in MongoDB Atlas (a cloud database), not in server memory. If you stop and restart the server with npm run dev, the books will still be there when you send a GET request.
Getting a 404 or empty array?
Make sure you are sending requests to http://localhost:3000/api/books (with /api prefix), not http://localhost:3000/books. The app.use('/api', bookRouter) line in server.js adds the /api prefix to all routes.
Help me debug my API endpoints not returning data
All five endpoints are working. Next up, you will add proper error handling so your API responds gracefully when something goes wrong instead of crashing.
Add Error Handling Middleware
Your CRUD routes are working, but what happens when something goes wrong? Right now, if someone sends a malformed ID or forgets a required field, the server could crash or return a confusing error.
In this step, you'll build centralized error handling middleware that catches different types of errors and returns clean, structured JSON responses. This keeps your route handlers focused on their job while a single function handles all error formatting.
In this step, get ready to:
- Create a centralized error handler that catches Mongoose validation and cast errors.
- Register the error handler as the last middleware in your server.
- Test error scenarios with Thunder Client to confirm clean JSON responses.
Create centralized error handling
Your routes already pass errors to next() when something fails. Now you need a function that receives those errors and decides what to send back to the client.
- Create the middleware folder and the error handler file by running these commands in your terminal:
mkdir middleware
touch middleware/errorHandler.js
- Confirm the folder and file exist by running:
ls middleware
You should see errorHandler.js in the output.
- Open middleware/errorHandler.js in VS Code and add the following code:
const errorHandler = (err, req, res, next) => {
// Mongoose validation error
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
return res.status(400).json({ error: 'Validation failed', details: messages });
}
// Mongoose cast error (invalid ObjectId)
if (err.name === 'CastError') {
return res.status(400).json({ error: 'Invalid ID format' });
}
// Default server error
console.error('Unexpected error:', err);
res.status(500).json({ error: 'Internal server error' });
};
module.exports = errorHandler;
What does this code do?
Express error middleware always takes four parameters: err, req, res, next. The four-parameter signature is how Express knows this function handles errors instead of regular requests.
- ValidationError catches when a required field is missing or data doesn't match the schema. It extracts the specific error messages and returns them in a details array.
- CastError catches when someone passes a malformed MongoDB ObjectId (like abc123 instead of a valid 24-character hex string).
- The default case logs the full error to your terminal for debugging and sends a generic message to the client so internal details stay hidden.
- Save middleware/errorHandler.js.
You won't see a change yet. The next edit connects this handler to your server.
Now you need to register the error handler in your server. Express processes middleware in order, so error handlers must come after all routes.
- In server.js, find this line near the top of your imports:
const bookRouter = require('./routes/bookRoutes');
- Add this line directly below it:
const errorHandler = require('./middleware/errorHandler');
- Now scroll down and find this line:
app.use('/api', bookRouter);
- Add this line directly below it:
app.use(errorHandler);
Why does order matter?
Express runs middleware from top to bottom. If the error handler came before your routes, errors thrown by routes would have nowhere to go. Placing it after all routes ensures it catches any errors that bubble up from your endpoint handlers.
- Save server.js.
Nodemon restarts automatically. Check your terminal to confirm you still see "Connected to MongoDB" and "Server running on port 3000".
Server crashed after saving?
- Check that the path in your require statement matches exactly: ./middleware/errorHandler. A typo in the folder or file name causes a "Cannot find module" error.
- Make sure app.use(errorHandler) comes after app.use('/api', bookRouter) and not inside the startServer() function.
Help me debug my error handler registration in server.js
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on to testing.
ⓧ I'd like to double check the full code
Here's the full middleware/errorHandler.js:
const errorHandler = (err, req, res, next) => {
// Mongoose validation error
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
return res.status(400).json({ error: 'Validation failed', details: messages });
}
// Mongoose cast error (invalid ObjectId)
if (err.name === 'CastError') {
return res.status(400).json({ error: 'Invalid ID format' });
}
// Default server error
console.error('Unexpected error:', err);
res.status(500).json({ error: 'Internal server error' });
};
module.exports = errorHandler;
Here's the full server.js:
const dotenv = require('dotenv');
dotenv.config();
const express = require('express');
const mongoose = require('mongoose');
const bookRouter = require('./routes/bookRoutes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Bookstore API' });
});
app.use('/api', bookRouter);
app.use(errorHandler);
async function startServer() {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log('Connected to MongoDB');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
} catch (error) {
console.error('Failed to connect to MongoDB:', error.message);
process.exit(1);
}
}
startServer();
Test error scenarios with Thunder Client
Your routes already wrap each handler in try-catch and pass errors to next(). Now that the error handler is connected, those errors flow through your middleware and return structured responses.
- Open Thunder Client from the sidebar in VS Code.
- Create a new request.
- Set the method to GET and enter this URL:
http://localhost:3000/api/books/not-a-valid-id
- Click Send.
You should see a 400 response with this JSON body:
{
"error": "Invalid ID format"
}
What just happened?
The string not-a-valid-id is not a valid MongoDB ObjectId. Mongoose threw a CastError when trying to look it up, your route's catch block passed it to next(), and your error handler caught it and returned a clean 400 response.
Now test a validation error by trying to create a book without the required fields.
- Create a new request in Thunder Client.
- Set the method to POST and the URL to:
http://localhost:3000/api/books
- Click the Body tab, select JSON, and enter this body with no title or author:
{
"genre": "Fantasy",
"pages": 300
}
- Click Send.
You should see a 400 response with both validation error messages:
{
"error": "Validation failed",
"details": [
"Book title is required",
"Author name is required"
]
}
Finally, test that a PUT request with an invalid ID also returns a structured error.
- Create a new request with method PUT and the URL:
http://localhost:3000/api/books/invalid-id-here
- Set the Body to JSON with any content (e.g. {"title": "Test"}).
- Click Send and confirm you get the same Invalid ID format error with status 400.
Why is this better than crashing?
Without the error handler, an invalid ID would crash the server or return a raw stack trace to the client. Your middleware gives API consumers a consistent, predictable response format they can handle in their code.
Check your terminal. The server is still running after all those errors. That's the real win.
Getting a 500 instead of 400?
- Make sure your error handler checks err.name === 'ValidationError' and err.name === 'CastError' with exact capitalization. These are the names Mongoose uses internally.
- Verify that app.use(errorHandler) appears in server.js after app.use('/api', bookRouter). If it's before the routes, errors won't reach it.
Help me debug why my error handler returns 500 instead of 400
Your API now handles errors gracefully and keeps running no matter what users throw at it. This is the exact pattern used in production Express applications to separate error logic from business logic. Next up, you'll tackle a secret mission to make your API even more powerful with query filtering.
Secret mission
Add Query Filtering
Your API returns all books every time. What if a user only wants fantasy books, or books by a specific author? In this mission, you'll add dynamic query filtering so users can search by author, genre, or publication year.
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 a free MongoDB Atlas cluster and runs locally, so there are no ongoing costs.
Resources you used:
- MongoDB Atlas free cluster (cloud database).
- Local bookstore-api project folder (all source files and node_modules).
- Thunder Client VS Code extension.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with new endpoints.
- Your MongoDB Atlas M0 cluster is free forever. It will not incur charges.
- Your local project files stay on your Desktop.
- Thunder Client remains installed in VS Code for future API testing.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and cloud cluster stay intact.
- In the terminal where your server is running, press Ctrl+C to stop nodemon.
- Your MongoDB Atlas cluster stays running at no cost. No action needed there.
- Your local project files remain on your Desktop whenever you want to resume.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Stop the server by pressing Ctrl+C in the terminal.
- Delete the local project folder by running the command for your operating system:
macOS
cd ~/Desktop
rm -rf bookstore-api
Windows
cd ~\Desktop
rmdir /s /q bookstore-api
- Terminate your MongoDB Atlas cluster:
- Go to your MongoDB Atlas dashboard and click Clusters in the left sidebar under the Database heading.
- Click the ... (ellipsis) next to your cluster.
- Select Terminate from the drop-down menu.
- Enter the cluster name to confirm, then click Terminate.
- Uninstall Thunder Client: in VS Code, open the Extensions sidebar (Ctrl+Shift+X), find Thunder Client, and click Uninstall.
Nice Work!
Nice Work!
You just built a complete Bookstore REST API from scratch using Express and MongoDB. Your API can create, read, update, and delete books from a cloud database, validate incoming data, and handle errors gracefully.
Here's what you accomplished:
- Built a REST API with Express 5 and connected it to a cloud MongoDB Atlas database using Mongoose.
- Defined a data model with schema validation to enforce required fields and data types on every document.
- Implemented all five CRUD operations (Create, Read, Update, Delete) and added centralized error handling middleware for clean error responses.
- Secret Mission: Added dynamic query filtering with regex-based search so users can find books by author, genre, or publication year.
Ready to quiz yourself?