Build a complete create, read, update and delete REST API with Express 5, MongoDB and Mongoose 8, including schemas, validation and database error handling.

Bishal Biswas
Self Learner
What you will learn
- What MongoDB and Mongoose are, and how they fit with Express
- How to connect to MongoDB and start your server only when the database is ready
- How to define a schema and model with built-in validation
- How to write all five CRUD routes with Mongoose 8 methods
- How to turn database errors into clear
400,404and409responses
MongoDB and Mongoose in plain words
MongoDB is a document database. Instead of tables with rows, it stores documents: JSON-like objects. Documents live in collections, the way files live in folders.
{
"_id": "6718f3a2c4b1e2a9d0f1a2b3",
"title": "Dune",
"author": "Frank Herbert",
"pages": 412,
"tags": ["sci-fi", "classic"]
}
Because documents look like JavaScript objects, MongoDB feels natural in a Node.js app.
Mongoose is an ODM (Object Document Mapper). It sits between your code and MongoDB and adds structure: you describe what a book looks like, and Mongoose checks every book against that description before saving it.
Think of it like a filing cabinet with a form template. MongoDB is the cabinet; it will hold any paper you put in. Mongoose is the printed form that says “title: required, pages: a number above 0”. It stops half-filled forms from going into the drawer.
Step 1: Get a MongoDB database
You need a running MongoDB server. Pick one option.
Option A: Docker on your computer. If you have Docker installed, one command starts MongoDB:
docker run -d --name mongo -p 27017:27017 mongo
Option B: MongoDB Atlas. Atlas is MongoDB’s hosted cloud service and has a free tier. Create a cluster, add a database user, allow your IP address, and copy the connection string it gives you.
A connection string is a URL that tells Mongoose where the database is and how to log in.
Step 2: Set up the project
mkdir bookstore-api && cd bookstore-api
npm init -y
npm install express mongoose
Create this structure:
bookstore-api/
.env
package.json
src/
app.js
db.js
server.js
models/
book.js
routes/
books.js
middleware/
error-handler.js
Add the connection string to .env. The last part of the URL (bookstore) is the database name. MongoDB creates it automatically on first write.
# .env
PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/bookstore
Tip: Use
127.0.0.1instead oflocalhostfor a local database. On some systemslocalhostresolves to the IPv6 address::1first, and the connection fails.
Update package.json so it uses ES modules and loads .env with Node’s built-in --env-file flag (Node.js 20.6 or newer):
{
"name": "bookstore-api",
"type": "module",
"scripts": {
"dev": "node --env-file=.env --watch src/server.js",
"start": "node --env-file=.env src/server.js"
}
}
Step 3: Connect to the database
// src/db.js
import mongoose from 'mongoose';
export const connectToDatabase = async (uri) => {
await mongoose.connect(uri);
console.log('Connected to MongoDB');
};
Keep the Express app in its own file, and start listening only after the database is connected. Otherwise the first requests would fail while the connection is still opening.
// src/server.js
import app from './app.js';
import { connectToDatabase } from './db.js';
const port = Number(process.env.PORT) || 3000;
await connectToDatabase(process.env.MONGODB_URI); // top-level await works in ES modules
app.listen(port, (error) => {
if (error) throw error;
console.log(`API on http://localhost:${port}`);
});
If the database is unreachable, mongoose.connect rejects, the error is printed, and the process exits. That is what you want: a server without its database is not useful.
Step 4: Define a schema and a model
A schema describes the shape of a document: field names, types and rules. A model is the object you use to query one collection, built from a schema.
// src/models/book.js
import mongoose from 'mongoose';
const { Schema, model } = mongoose;
const bookSchema = new Schema(
{
title: {
type: String,
required: [true, 'Title is required'],
trim: true,
maxlength: [200, 'Title must be 200 characters or fewer'],
},
author: { type: String, required: [true, 'Author is required'], trim: true },
pages: { type: Number, min: [1, 'Pages must be at least 1'] },
isbn: { type: String, unique: true, sparse: true, trim: true },
tags: { type: [String], default: [] },
published: { type: Boolean, default: false },
},
{
timestamps: true, // adds createdAt and updatedAt automatically
toJSON: {
versionKey: false, // hide the internal __v field
transform: (doc, ret) => {
ret.id = ret._id.toString(); // clients get "id" instead of "_id"
delete ret._id;
return ret;
},
},
},
);
export const Book = model('Book', bookSchema); // collection name: "books"
A few things to notice:
required,minandmaxlengthare validators. Mongoose runs them before saving and refuses bad data.unique: trueis not a validator. It creates a unique index in MongoDB, a rule the database itself enforces.sparse: truelets several books have no ISBN at all.- The
toJSONoption controls whatres.json(book)sends, so your API returns a tidyidfield.
Step 5: Write the CRUD routes
Express 5 forwards errors from async handlers to your error handler automatically. So there is no try/catch in these routes: if a database call fails, the error goes straight to the error handler you will write in step 6.
// src/routes/books.js
import { Router } from 'express';
import { Book } from '../models/book.js';
const router = Router();
// Only these fields may come from the client
const FIELDS = ['title', 'author', 'pages', 'isbn', 'tags', 'published'];
const pickFields = (body = {}) =>
Object.fromEntries(FIELDS.filter((key) => key in body).map((key) => [key, body[key]]));
// CREATE
router.post('/', async (req, res) => {
const book = await Book.create(pickFields(req.body));
res.status(201).location(`/books/${book.id}`).json({ data: book });
});
// READ ALL
router.get('/', async (req, res) => {
const books = await Book.find().sort({ createdAt: -1 }).limit(50);
res.json({ data: books });
});
// READ ONE
router.get('/:id', async (req, res) => {
const book = await Book.findById(req.params.id);
if (!book) return res.status(404).json({ error: { message: 'Book not found' } });
res.json({ data: book });
});
// UPDATE (partial)
router.patch('/:id', async (req, res) => {
const book = await Book.findByIdAndUpdate(req.params.id, pickFields(req.body), {
new: true, // return the updated document, not the old one
runValidators: true, // apply schema rules to updates too
});
if (!book) return res.status(404).json({ error: { message: 'Book not found' } });
res.json({ data: book });
});
// DELETE
router.delete('/:id', async (req, res) => {
const book = await Book.findByIdAndDelete(req.params.id);
if (!book) return res.status(404).json({ error: { message: 'Book not found' } });
res.status(204).end();
});
export default router;
Here is what each Mongoose method does:
| Method | What it does | Returns |
|---|---|---|
Book.create(data) |
Validates and inserts a new document | The saved document |
Book.find(filter) |
Finds all matching documents | An array (maybe empty) |
Book.findById(id) |
Finds one document by _id |
The document or null |
Book.findByIdAndUpdate(id, data, options) |
Updates one document | The document or null |
Book.findByIdAndDelete(id) |
Deletes one document | The deleted document or null |
Warning: Without
{ new: true },findByIdAndUpdatereturns the document as it was before the update. WithoutrunValidators: true, updates skip your schema rules, so a client could setpagesto-5.
Why pickFields? Mongoose already drops fields that are not in the schema. But a client could still send fields that are in the schema and that you did not mean to expose, such as createdAt. Picking known fields, or validating with Zod as in Request Validation with Zod, keeps you in control.
Step 6: Turn database errors into good responses
Three database errors are really the client’s fault, so they should not become 500 errors.
| Error | When it happens | Response |
|---|---|---|
CastError |
The id is not a valid MongoDB ObjectId, e.g. /books/abc |
400 |
ValidationError |
A schema rule failed, e.g. missing title | 400 with details |
Duplicate key (code 11000) |
A unique field already exists, e.g. same ISBN | 409 |
// src/middleware/error-handler.js
export const errorHandler = (err, req, res, next) => {
if (res.headersSent) return next(err);
if (err.name === 'CastError') {
return res.status(400).json({ error: { message: `Invalid ${err.path}: ${err.value}` } });
}
if (err.name === 'ValidationError') {
const details = Object.values(err.errors).map((e) => ({ field: e.path, message: e.message }));
return res.status(400).json({ error: { message: 'Validation failed', details } });
}
if (err.code === 11000) {
const fields = Object.keys(err.keyValue ?? {});
return res.status(409).json({ error: { message: `Duplicate value for: ${fields.join(', ')}` } });
}
console.error(err);
res.status(500).json({ error: { message: 'Something went wrong' } });
};
Step 7: Wire up the app
// src/app.js
import express from 'express';
import booksRouter from './routes/books.js';
import { errorHandler } from './middleware/error-handler.js';
const app = express();
app.use(express.json());
app.use('/books', booksRouter);
app.use((req, res) => {
res.status(404).json({ error: { message: 'Route not found' } });
});
app.use(errorHandler);
export default app;
Start it:
npm run dev
Connected to MongoDB
API on http://localhost:3000
Step 8: Test every route
# Create
curl -i -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-d '{"title":"Dune","author":"Frank Herbert","pages":412,"isbn":"9780441013593"}'
{
"data": {
"title": "Dune",
"author": "Frank Herbert",
"pages": 412,
"isbn": "9780441013593",
"tags": [],
"published": false,
"createdAt": "2026-10-25T09:34:12.345Z",
"updatedAt": "2026-10-25T09:34:12.345Z",
"id": "6718f3a2c4b1e2a9d0f1a2b3"
}
}
Copy the id from your own response, then:
curl http://localhost:3000/books
curl http://localhost:3000/books/6718f3a2c4b1e2a9d0f1a2b3
curl -X PATCH http://localhost:3000/books/6718f3a2c4b1e2a9d0f1a2b3 \
-H "Content-Type: application/json" \
-d '{"published":true}'
curl -i -X DELETE http://localhost:3000/books/6718f3a2c4b1e2a9d0f1a2b3
Now try the error cases:
# 400: missing title
curl -X POST http://localhost:3000/books -H "Content-Type: application/json" -d '{"author":"Anon"}'
# 400: not a valid ObjectId
curl http://localhost:3000/books/abc
# 404: valid id format, but no such book
curl http://localhost:3000/books/6718f3a2c4b1e2a9d0f1a2b4
# 409: create the same ISBN twice
curl -X POST http://localhost:3000/books -H "Content-Type: application/json" \
-d '{"title":"Copy","author":"X","isbn":"9780441013593"}'
Note: If the
409test does not fail, the unique index may not exist yet. Mongoose builds indexes in the background when the app starts. If you addeduniqueafter documents with duplicate values already existed, MongoDB cannot build the index. Delete the duplicates and restart.
Common mistakes
- Forgetting
await.const book = Book.findById(id)gives you a query object, not a book. AlwaysawaitMongoose calls. - Starting the server before connecting. Connect first, then call
app.listen. - Forgetting
new: trueandrunValidators: trueonfindByIdAndUpdate. - Treating every error as
500. Bad ids and failed validation are client errors. HandleCastError,ValidationErrorand code11000. - Passing
req.bodyorreq.querystraight into queries. A body like{ "title": { "$ne": null } }could change what a query matches. Validate input first. - Committing
.env. Add it to.gitignore, especially when it holds an Atlas password.
Key takeaways
- MongoDB stores JSON-like documents in collections. Mongoose adds schemas, validation and a friendly query API.
- Connect with
mongoose.connect(uri)beforeapp.listen. - A schema defines fields and validators;
model()turns it into something you can query. - CRUD maps to
create,find/findById,findByIdAndUpdate(withnewandrunValidators) andfindByIdAndDelete. - Express 5 forwards async errors for you; translate
CastError,ValidationErrorand duplicate keys into400and409.
Practice
- Add a
genrefield that only accepts'fiction','non-fiction'or'poetry'(hint: theenumschema option). - Add
GET /books?author=Frank%20Herbertthat filters by exact author. Read the value fromreq.query.authorand check that it is a string before using it. - Validate the create body with a Zod schema before calling
Book.create. - Add an
authorscollection and store an author’s id on each book withtype: Schema.Types.ObjectId, ref: 'Author'. Look up Mongoose’spopulate()to return the author’s name with each book.
What’s next
MongoDB is one popular choice. Many apps use a relational SQL database instead, so next you will build the same API in CRUD API with SQL and an ORM.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready