REST CRUD API with Node.js, Express and MongoDB
Node.js 12 min read
A complete CRUD service on Express 5 and Mongoose 8: schema validation, the update options that make validation actually run, a CastError handler so a malformed id is a 400, and graceful shutdown.
The interesting parts of a CRUD API are not the four handlers. They are what happens when the input is wrong: a malformed id, a missing field, a duplicate key, a database that is not there yet. Most tutorials leave all four to a default error handler that returns a 500 and a stack trace.
This builds the endpoints and then handles those cases.
Written against Node 20, Express 5 and Mongoose 8, using ES modules.
Project setup
$ mkdir notes-api && cd notes-api
$ npm init -y
$ npm install express mongoose dotenv
$ npm install --save-dev nodemon
{
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
}
}
"type": "module" gives you import rather than require. Node has supported it for years and it
is the right default for new code.
src/
├── server.js # process concerns: listen, shutdown
├── app.js # the Express app, exportable for tests
├── db.js # the connection
├── models/note.js
├── routes/notes.js
├── controllers/notes.js
└── middleware/errors.js
Separating app.js from server.js is what makes the app testable: a test imports the app and
never binds a port.
Configuration
# .env
PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/notes
// src/db.js
import mongoose from 'mongoose';
export async function connect(uri) {
mongoose.set('strictQuery', true);
await mongoose.connect(uri, {
serverSelectionTimeoutMS: 5000,
});
mongoose.connection.on('error', (err) => {
console.error('mongo error', err);
});
return mongoose.connection;
}
serverSelectionTimeoutMS matters more than it looks, the default is 30 seconds, so a wrong
connection string means a container that appears to hang at startup for half a minute before saying
anything. Five seconds fails fast and tells you.
Use 127.0.0.1 rather than localhost in the URI. On a machine resolving localhost to IPv6 first,
a MongoDB listening only on IPv4 produces a connection refused that looks like the server is down.
The model
// src/models/note.js
import mongoose from 'mongoose';
const noteSchema = new mongoose.Schema(
{
title: {
type: String,
required: [true, 'title is required'],
trim: true,
maxlength: [200, 'title cannot exceed 200 characters'],
},
content: {
type: String,
required: [true, 'content is required'],
trim: true,
},
tags: {
type: [String],
default: [],
validate: {
validator: (v) => v.length <= 10,
message: 'at most 10 tags',
},
},
published: { type: Boolean, default: false },
},
{
timestamps: true,
versionKey: false,
}
);
noteSchema.index({ title: 'text', content: 'text' });
export const Note = mongoose.model('Note', noteSchema);
timestamps: true adds and maintains createdAt and updatedAt. Writing those by hand is how they
end up inconsistent.
Custom messages on the validators are worth the extra lines. They become the API’s error messages,
and Path 'title' is required. is not something you want a client to read.
The controller
// src/controllers/notes.js
import { Note } from '../models/note.js';
export async function create(req, res) {
const note = await Note.create({
title: req.body.title,
content: req.body.content,
tags: req.body.tags,
});
res.status(201).location(`/api/notes/${note.id}`).json(note);
}
export async function list(req, res) {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Number(req.query.limit) || 20);
const [items, total] = await Promise.all([
Note.find().sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit),
Note.countDocuments(),
]);
res.json({ items, page, limit, total });
}
export async function get(req, res) {
const note = await Note.findById(req.params.id);
if (!note) {
return res.status(404).json({ message: 'note not found' });
}
res.json(note);
}
export async function update(req, res) {
const note = await Note.findByIdAndUpdate(
req.params.id,
{ title: req.body.title, content: req.body.content, tags: req.body.tags },
{ new: true, runValidators: true }
);
if (!note) {
return res.status(404).json({ message: 'note not found' });
}
res.json(note);
}
export async function remove(req, res) {
const note = await Note.findByIdAndDelete(req.params.id);
if (!note) {
return res.status(404).json({ message: 'note not found' });
}
res.status(204).end();
}
Two things in update are the whole reason that function is interesting.
{ new: true } returns the document after the update. Without it Mongoose returns the old one,
so the response shows the previous values and the API looks broken while working correctly.
{ runValidators: true } is off by default on update operations. Without it, your schema
validation applies to create and silently not to update, so a title can be set to a
300-character string through the endpoint that skipped the check. This is the single most common real
defect in Mongoose CRUD code.
Note also what the handlers do not contain: try/catch. Express 5 forwards a rejected promise
from an async handler to the error middleware automatically. On Express 4 you need a wrapper, which is
why so much older code is wrapped in catch(next).
Assign the fields explicitly rather than passing req.body through. Spreading the request body lets a
client set published: true, or any other field it discovers.
Routes
// src/routes/notes.js
import { Router } from 'express';
import * as notes from '../controllers/notes.js';
export const noteRoutes = Router();
noteRoutes.post('/', notes.create);
noteRoutes.get('/', notes.list);
noteRoutes.get('/:id', notes.get);
noteRoutes.put('/:id', notes.update);
noteRoutes.delete('/:id', notes.remove);
// src/app.js
import express from 'express';
import { noteRoutes } from './routes/notes.js';
import { notFound, errorHandler } from './middleware/errors.js';
export function createApp() {
const app = express();
app.use(express.json({ limit: '100kb' }));
app.get('/healthz', (req, res) => res.json({ status: 'ok' }));
app.use('/api/notes', noteRoutes);
app.use(notFound); // no route matched
app.use(errorHandler); // must be last, and must take four arguments
return app;
}
express.json({ limit: '100kb' }), the default limit is 100kb, and stating it makes the decision
visible. Without any body parser, req.body is undefined rather than empty, which produces a
confusing Cannot read properties of undefined.
Errors: the part that matters
// src/middleware/errors.js
export function notFound(req, res) {
res.status(404).json({ message: `no route for ${req.method} ${req.originalUrl}` });
}
// four parameters — Express identifies error middleware by arity
export function errorHandler(err, req, res, next) {
// a malformed ObjectId reaches here as a CastError
if (err.name === 'CastError' && err.kind === 'ObjectId') {
return res.status(400).json({ message: `'${err.value}' is not a valid id` });
}
if (err.name === 'ValidationError') {
return res.status(422).json({
message: 'validation failed',
errors: Object.values(err.errors).map((e) => ({
field: e.path,
message: e.message,
})),
});
}
if (err.code === 11000) {
const field = Object.keys(err.keyPattern ?? {})[0] ?? 'field';
return res.status(409).json({ message: `${field} already exists` });
}
if (err.type === 'entity.too.large') {
return res.status(413).json({ message: 'request body too large' });
}
console.error(err);
res.status(500).json({ message: 'internal server error' });
}
The CastError branch is the one every tutorial omits. GET /api/notes/not-an-id cannot even be
turned into an ObjectId, so Mongoose throws before any query runs, and without this branch the API
answers a client’s typo with a 500 and a stack trace. It is a 400: the request was malformed.
11000 is MongoDB’s duplicate-key error, which belongs to a unique index rather than to schema
validation, so it arrives as a driver error rather than a ValidationError.
Never send err.message in the 500 branch. It leaks connection strings, file paths and query
fragments to whoever provoked it.
Starting and stopping
// src/server.js
import 'dotenv/config';
import { createApp } from './app.js';
import { connect } from './db.js';
import mongoose from 'mongoose';
const port = process.env.PORT || 3000;
const connection = await connect(process.env.MONGODB_URI);
console.log(`mongo connected to ${connection.name}`);
const server = createApp().listen(port, () => {
console.log(`listening on ${port}`);
});
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
console.log(`${signal} received, closing`);
server.close(async () => {
await mongoose.connection.close();
process.exit(0);
});
});
}
Connect before listening. Binding the port first means the container reports healthy and then fails every request until the database appears.
The signal handlers are not optional in a container. Without them the process is killed mid-request
on every deployment; server.close stops accepting connections and drains the ones in flight.
Trying it
$ curl -s -X POST localhost:3000/api/notes \
-H 'Content-Type: application/json' \
-d '{"title":"Shopping","content":"milk, bread","tags":["home"]}'
{"tags":["home"],"published":false,"_id":"66c1...","title":"Shopping",
"content":"milk, bread","createdAt":"2026-...","updatedAt":"2026-..."}
$ curl -s -X POST localhost:3000/api/notes \
-H 'Content-Type: application/json' -d '{"content":"no title"}'
{"message":"validation failed","errors":[{"field":"title","message":"title is required"}]}
$ curl -s localhost:3000/api/notes/not-an-id
{"message":"'not-an-id' is not a valid id"}
$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/api/notes/66c1...
204
Four responses, four correct status codes, no stack traces.
Frequently asked questions
Why does my update return the old document?
findByIdAndUpdate returns the pre-update document
unless you pass { new: true }.
Why is schema validation skipped on update?
Validators do not run on update operations by
default. Pass { runValidators: true }. This is the most common real bug in Mongoose CRUD code.
Why does an invalid id return 500?
Mongoose throws a CastError before querying, because the
string cannot become an ObjectId. Handle it in the error middleware and return 400.
Do I need try/catch in async handlers?
Not on Express 5, which forwards rejected promises to the
error middleware. On Express 4 you need a wrapper that calls next(err).
Why is req.body undefined?
No body parser is registered. Add app.use(express.json()) before the
routes.
Why is my error handler never called?
Error middleware is identified by taking four arguments
(err, req, res, next). With three it is treated as ordinary middleware. It must also be registered
last.
What is error code 11000?
MongoDB’s duplicate-key error from a unique index. It is a driver
error rather than a Mongoose ValidationError, so it needs its own branch, a 409 is the right
status.
Should I pass req.body straight to create?
No. Assign fields explicitly, or a client can set any field in your schema, including ones you never exposed.
Why does connecting hang for 30 seconds?
That is the default serverSelectionTimeoutMS. Lower it
to a few seconds so a wrong URI fails fast.
Do I need SIGTERM handling?
In a container, yes. Without it every deployment kills the process mid-request instead of draining connections.
Where should I go next?
The Node.js guides cover the surrounding runtime, and Docker containers for Go covers the same packaging questions for a compiled service.