CRUD with Mongoose: Create, Read, Update and Delete in Express

MERN Stack Full Course: MongoDB, Express, React and Node.js · MongoDB and Mongoose · 6 min read · Updated

CRUD: four verbs, four calls

  1. Create, read, update and delete are the four things almost every app does with data.
  2. POST creates a job with Job.create. Only the allowed fields are taken from the body.
  3. GET finds one job by its id. A malformed id gives 400 and a missing job gives 404.
  4. PATCH updates with runValidators on, so the rules apply to changes too.
  5. DELETE removes the job and replies 204 with no body.

This is the lesson where the Job Tracker becomes real. The routes and the router stay exactly as they were. Only the body of each controller function changes, from “work on an array” to “work on MongoDB”. That is the payoff of the structure from lesson 11.

The four operations

// Lesson 17: the same routes, now backed by MongoDB.
// Express 5 passes errors from async functions to the error handler, so we do not need try/catch everywhere.
import Job from '../models/Job.js';
import { HttpError } from '../utils/HttpError.js';
import { pickJobFields } from '../middleware/validate.js';

const mustFind = (job) => {
  if (!job) throw new HttpError(404, 'Job not found');
  return job;
};

export const listJobs = async (req, res) => {
  const jobs = await Job.find().sort({ createdAt: -1 });
  res.json(jobs);
};

export const getJob = async (req, res) => res.json(mustFind(await Job.findById(req.params.id)));

export const createJob = async (req, res) => {
  const job = await Job.create(pickJobFields(req.body)); // Create: validation runs automatically
  res.status(201).json(job);
};

export const updateJob = async (req, res) => {
  // new: true returns the changed document; runValidators applies the schema rules to the update too
  const job = await Job.findByIdAndUpdate(req.params.id, pickJobFields(req.body), { new: true, runValidators: true });
  res.json(mustFind(job));
};

export const deleteJob = async (req, res) => {
  mustFind(await Job.findByIdAndDelete(req.params.id));
  res.status(204).end();
};
  • Create: Job.create(data) builds a document, runs validation and saves it. It returns the saved document, including its new _id.
  • Read: Job.find(filter) returns an array; Job.findById(id) returns one document or null. Chain .sort({ createdAt: -1 }) to order newest first.
  • Update: Job.findByIdAndUpdate(id, changes, options). The option new: true returns the document *after* the change (the default returns the old one). runValidators: true is essential: update operations skip the schema rules unless you ask for them, which would let { status: 'maybe' } slip in.
  • Delete: Job.findByIdAndDelete(id) removes it and returns the removed document, or null.

Every one of these is asynchronous, so every one needs await. Because the controller functions are async and Express 5 forwards rejected promises to the error handler, you do not need try/catch in each one. A validation failure inside create simply throws, and the error middleware answers.

Do not trust the body: pick the fields

import mongoose from 'mongoose';
import { HttpError } from '../utils/HttpError.js';

// router.param('id', checkId): runs for every route that has :id and stops bad ids early.
export const checkId = (req, res, next, id) => {
  if (!mongoose.isValidObjectId(id)) return next(new HttpError(404, 'Job not found'));
  next();
};

// Only copy the fields we allow. If a client sends { "user": "someone else", "isAdmin": true }, they are ignored.
const JOB_FIELDS = ['company', 'role', 'location', 'status', 'salaryNote', 'notes', 'appliedOn'];
export const pickJobFields = (body = {}) =>
  Object.fromEntries(JOB_FIELDS.filter((f) => body[f] !== undefined).map((f) => [f, body[f]]));

pickJobFields copies only the fields we allow. Passing req.body straight to Job.create is a classic security hole: a client could add fields you never meant to be settable. A whitelist makes the allowed fields explicit. (In lesson 22 the owner field is added by the server from the signed-in user and never read from the body, which is the most important use of this habit.)

checkId is a param middleware: router.param('id', checkId) runs for every route that has :id in the path. A text that is not a valid ObjectId would otherwise make Mongoose throw a CastError. Here it answers 404 straight away, which also avoids confirming anything about how ids look.

import { Router } from 'express';
import { listJobs, getJob, createJob, updateJob, deleteJob } from '../controllers/jobs.js';
import { checkId } from '../middleware/validate.js';

const router = Router();
router.param('id', checkId);

router.get('/', listJobs);
router.post('/', createJob);
router.get('/:id', getJob);
router.put('/:id', updateJob);
router.delete('/:id', deleteJob);

export default router;

Translating database errors

export const notFound = (req, res) => {
  res.status(404).json({ message: `No route for ${req.method} ${req.originalUrl}` });
};

// Turn the errors Mongoose and MongoDB throw into friendly answers.
function translate(err) {
  if (err.name === 'ValidationError') {
    const details = Object.fromEntries(Object.entries(err.errors).map(([k, e]) => [k, e.message]));
    return { status: 400, message: 'Please fix the highlighted fields', details };
  }
  if (err.name === 'CastError') return { status: 400, message: `${err.path} is not valid` };
  if (err.code === 11000) {
    const field = Object.keys(err.keyPattern || {})[0] || 'value';
    return { status: 409, message: `That ${field} is already used`, details: { [field]: `That ${field} is already used` } };
  }
  if (err.type === 'entity.parse.failed') return { status: 400, message: 'The request body is not valid JSON' };
  return null;
}

// eslint-disable-next-line no-unused-vars
export const errorHandler = (err, req, res, next) => {
  const known = translate(err);
  const status = known?.status || err.status || 500;
  if (status >= 500) console.error(err);
  res.status(status).json({
    message: known?.message || (status >= 500 ? 'Something went wrong on the server' : err.message),
    ...((known?.details || err.details) ? { details: known?.details || err.details } : {}),
  });
};

translate recognises the errors that Mongoose and MongoDB throw and turns them into friendly answers. A ValidationError becomes 400 with one message per field, in the same shape as lesson 12, so a React form can show them. A CastError becomes 400. A MongoDB duplicate-key error (code 11000, which you will meet for emails in lesson 20) becomes 409 Conflict. Anything else stays a generic 500 and is logged.

Your task

  1. Start the server with your connection string and run npm run smoke.
  2. Open your database in Atlas (or Compass) and find the jobs collection that was created by the first POST.
  3. Restart the server and call GET /api/jobs. The data is still there. That is persistence.
  4. Try to update a job with { "status": "maybe" }. Then remove runValidators: true from the update and try again. What changed?

How to check it worked

  • The smoke script prints All checks passed.
  • Data survives a server restart.
  • An invalid status on update answers 400 with a message for status.

Summary

  • create, find, findById, findByIdAndUpdate, findByIdAndDelete cover CRUD.
  • Updates need { new: true, runValidators: true }.
  • Whitelist body fields; validate ids; translate database errors into 400, 404 and 409.

Download the solution (mern-lesson-17.zip)

Frequently asked questions

Why does my update accept invalid data?

Update methods such as findByIdAndUpdate do not run schema validators unless you pass runValidators: true. Add it to every update, and also use new: true to receive the changed document.

What is a CastError?

Mongoose throws it when it cannot convert a value to the schema type, most often a text that is not a valid ObjectId in a /:id route. Validate the id first and answer 404 or 400.

Should I use try/catch in every controller?

Not with Express 5. It passes errors thrown or rejected inside async handlers to your error middleware automatically. Use try/catch only when you need to clean up or handle a specific error locally.