A file is fine for learning, but real applications keep data in a database. MongoDB is a popular choice for Node.js projects, and it is the M in the MERN stack. It stores data as documents, which look like JavaScript objects, grouped in collections, which are like tables. Mongoose is a library that sits between your code and MongoDB and adds structure: schemas, validation and handy query functions. In this lesson you will replace the file store with MongoDB.
Install and connect
Install the library in your project, and add the connection string to .env. For a local server it looks like mongodb://127.0.0.1:27017/taskapi. Atlas gives you a longer address that includes a user name and a password, and so it is a secret: it belongs in .env and never in git.
npm install mongoose
# .env
MONGODB_URI=mongodb://127.0.0.1:27017/taskapi
import mongoose from 'mongoose';
const uri = process.env.MONGODB_URI;
if (!uri) {
console.error('Set MONGODB_URI in your .env file.');
process.exit(1);
}
await mongoose.connect(uri, { serverSelectionTimeoutMS: 5000 });
console.log('Connected to MongoDB');
mongoose.connect returns a promise. await it before you start the server, so no request arrives before the database is ready. The option serverSelectionTimeoutMS makes the program fail after five seconds if it cannot reach the database, and not wait for a long time. In server.js, put the connect step before app.listen.
Schemas and models
A schema describes the shape of a document: its fields, their types and rules. A model is built from a schema and gives you the functions to read and write that collection. Mongoose creates the collection from the model name, in lowercase and plural, so Task becomes tasks.
import mongoose from 'mongoose';
const taskSchema = new mongoose.Schema(
{
title: { type: String, required: [true, 'title is required'], trim: true, maxlength: 120 },
done: { type: Boolean, default: false },
},
{ timestamps: true },
);
export const Task = mongoose.model('Task', taskSchema);
requiredwith a message: saving without a title fails with that message.trimremoves spaces at both ends.maxlengthlimits the length.default: falsefills indonewhen it is not given.timestamps: trueaddscreatedAtandupdatedAtand keeps them up to date.- Mongoose adds an
_idfield to every document. It is anObjectId, a 24-character identifier, and not a small number like our earlier ids.
You can check a document without a database by calling await new Task({ title: ' ' }).validate(). It rejects with an error whose errors.title.message is title is required, because trimming leaves an empty text. A document with the title Read docs passes, and its title becomes Read docs. Schema rules are a second layer of protection after your own checkTask function.
The routes with Mongoose
The routes now call the model. Compare them with the store version. Every function is async, which Express 5 handles for us.
import { Router } from 'express';
import mongoose from 'mongoose';
import { Task } from './models.js';
import { checkTask } from './validate.js';
export const tasksRouter = Router();
// A bad id would make Mongoose throw a CastError, so check it first
const validId = (req, res, next) => {
if (!mongoose.isValidObjectId(req.params.id)) return res.status(404).json({ error: 'Task not found' });
next();
};
tasksRouter.get('/', async (req, res) => {
const filter = {};
if (req.query.done === 'true') filter.done = true;
if (req.query.done === 'false') filter.done = false;
res.json(await Task.find(filter).sort({ createdAt: -1 }).limit(100));
});
tasksRouter.get('/:id', validId, async (req, res) => {
const task = await Task.findById(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
tasksRouter.post('/', async (req, res) => {
const { value, error } = checkTask(req.body);
if (error) return res.status(400).json({ error });
res.status(201).json(await Task.create(value));
});
tasksRouter.patch('/:id', validId, async (req, res) => {
const { value, error } = checkTask(req.body, { partial: true });
if (error) return res.status(400).json({ error });
const task = await Task.findByIdAndUpdate(req.params.id, value, { returnDocument: 'after', runValidators: true });
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
tasksRouter.delete('/:id', validId, async (req, res) => {
const task = await Task.findByIdAndDelete(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.status(204).end();
});
Look at the functions used. Task.find(filter) returns the matching documents, .sort({ createdAt: -1 }) puts the newest first and .limit(100) caps the list, so one request can never pull in a million documents. findById finds one. Task.create(value) validates and inserts. findByIdAndUpdate changes a document, and the option returnDocument: 'after' returns the new version, while runValidators: true applies the schema rules to updates, which Mongoose does not do by default. findByIdAndDelete removes it.
The small validId middleware matters. If a client calls /api/tasks/abc, Mongoose cannot turn abc into an ObjectId, and it throws a CastError. We check with mongoose.isValidObjectId first and answer 404, which is what the client should see.
Handling database errors
- A duplicate key error (code
11000) happens when a unique field repeats, for example two users with the same email. Catch it and answer 409. - A validation error from Mongoose has the name
ValidationError. Answer 400. - A connection problem is a server problem. Let it reach the error handler, which answers 500 and logs the details.
Safety rules for databases
- Never build a query from raw request data. For example,
Task.find(req.body)lets a client send operators such as{ "$gt": "" }and change the meaning of the query. This kind of attack is called NoSQL injection. Pick the fields you accept yourself, ascheckTaskdoes, and check that they are the types you expect. - Do not let clients choose which fields to update.
checkTaskcopies onlytitleanddone. - Use a database user with only the rights the app needs, never an admin account.
- Limit the size of lists with
.limit()and paging. - Keep backups, and test restoring one.
When you are ready, ask which indexes your queries need. An index lets MongoDB find documents without reading the whole collection. A field you search or sort by often, such as owner or createdAt, is a candidate. Add index: true to the field, and measure before you add many, since indexes cost space and slow down writes.
Your task
- Set up MongoDB locally or on Atlas. Put the connection string in
.env. - Install Mongoose, add
models.jsand connect beforeapp.listen. - Replace the store with the Mongoose routes. Create, read, update and delete a task with curl, and look at the document in a viewer such as MongoDB Compass.
- Try
/api/tasks/abcand a valid-looking id that does not exist. Both should give 404.
How to check it worked
- A task you created is still there after you restart the server.
- Updating
donereturns the document with the new value. /api/tasks/abcreturns 404 JSON and not a 500 error.