MongoDB and Mongoose: Store Your API Data in a Real Database

Node.js and Express: Build a Back End API from Scratch · Data, Login and Going Live · 7 min read · Updated

Express, Mongoose and MongoDB

  1. An Express route receives a request and checks the input.
  2. It calls the Mongoose model, for example Task.create or Task.find.
  3. Mongoose applies the schema rules and sends the query to MongoDB, which stores or returns documents.
  4. The result comes back and the route sends it to the client as JSON.

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);
  • required with a message: saving without a title fails with that message.
  • trim removes spaces at both ends. maxlength limits the length.
  • default: false fills in done when it is not given.
  • timestamps: true adds createdAt and updatedAt and keeps them up to date.
  • Mongoose adds an _id field to every document. It is an ObjectId, 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, as checkTask does, and check that they are the types you expect.
  • Do not let clients choose which fields to update. checkTask copies only title and done.
  • 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

  1. Set up MongoDB locally or on Atlas. Put the connection string in .env.
  2. Install Mongoose, add models.js and connect before app.listen.
  3. 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.
  4. Try /api/tasks/abc and 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 done returns the document with the new value.
  • /api/tasks/abc returns 404 JSON and not a 500 error.

Frequently asked questions

What is the difference between MongoDB and Mongoose?

MongoDB is the database. Mongoose is a Node.js library that adds schemas, validation and convenient query functions on top of it.

Why is my _id not a number?

MongoDB creates a 24-character ObjectId for every document. Treat it as text, and check ids with mongoose.isValidObjectId before querying.