Skip to content
Express.js ex data 5 min read

MongoDB with Mongoose

MongoDB stores documents as flexible JSON-like objects, but most production Express apps want structure: typed fields, validation, and a clean query API. Mongoose is the Object Document Mapper (ODM) that provides exactly that. It sits between your Express routes and the MongoDB driver, letting you define schemas, build models, and run queries with async/await. Express plus Mongoose is the canonical “MERN” data layer, and getting the connection, schema, and CRUD patterns right is the foundation everything else builds on.

Installing and connecting

Install Mongoose alongside Express. It bundles the official MongoDB driver, so you do not need a separate dependency.

npm install express mongoose

Open a single connection at application startup using mongoose.connect, which returns a promise. Keep one connection for the whole process — Mongoose maintains an internal connection pool, so you should never connect per request.

// db.js
const mongoose = require('mongoose');

async function connectDB() {
  await mongoose.connect(process.env.MONGO_URI, {
    serverSelectionTimeoutMS: 5000, // fail fast if the server is unreachable
  });
  console.log('MongoDB connected');
}

module.exports = connectDB;

Call it before you start listening, and exit if the connection fails so the process does not serve traffic against a dead database.

// server.js
const express = require('express');
const connectDB = require('./db');

const app = express();
app.use(express.json()); // parse JSON request bodies

connectDB()
  .then(() => app.listen(3000, () => console.log('Listening on :3000')))
  .catch((err) => {
    console.error('DB connection failed', err);
    process.exit(1);
  });

Tip: Store the connection string in an environment variable, never in source. A typical Atlas URI looks like mongodb+srv://user:[email protected]/mydb.

Defining a schema and model

A Schema declares the shape of your documents — field names, types, defaults, and validation rules. A model is a constructor compiled from that schema; you use it to create and query documents in a collection (Mongoose pluralizes the model name, so User maps to the users collection).

// models/User.js
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema(
  {
    name: { type: String, required: true, trim: true },
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      match: [/^\S+@\S+\.\S+$/, 'Invalid email'],
    },
    age: { type: Number, min: 0, max: 120 },
    role: { type: String, enum: ['user', 'admin'], default: 'user' },
  },
  { timestamps: true } // adds createdAt and updatedAt automatically
);

module.exports = mongoose.model('User', userSchema);

The timestamps option and enum, min, match, and unique constraints are enforced by Mongoose before anything reaches MongoDB, giving you typed, validated data without hand-written checks in every route.

CRUD queries with async/await

Every model method returns a thenable query, so you await it inside an async route handler. Below is a complete REST resource mounted on an Express Router.

// routes/users.js
const express = require('express');
const User = require('../models/User');

const router = express.Router();

// CREATE
router.post('/', async (req, res, next) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});

// READ (list + single)
router.get('/', async (req, res) => {
  const users = await User.find().sort({ createdAt: -1 }).limit(20);
  res.json(users);
});

router.get('/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'Not found' });
  res.json(user);
});

// UPDATE
router.patch('/:id', async (req, res, next) => {
  try {
    const user = await User.findByIdAndUpdate(req.params.id, req.body, {
      new: true, // return the updated document
      runValidators: true, // re-run schema validation on update
    });
    if (!user) return res.status(404).json({ error: 'Not found' });
    res.json(user);
  } catch (err) {
    next(err);
  }
});

// DELETE
router.delete('/:id', async (req, res) => {
  const result = await User.findByIdAndDelete(req.params.id);
  if (!result) return res.status(404).json({ error: 'Not found' });
  res.status(204).end();
});

module.exports = router;

Mount it in your app with app.use('/users', require('./routes/users')). A successful create responds like this:

Output:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{
  "_id": "665b1f...c2",
  "name": "Ada Lovelace",
  "email": "[email protected]",
  "role": "user",
  "createdAt": "2026-06-14T10:00:00.000Z",
  "updatedAt": "2026-06-14T10:00:00.000Z",
  "__v": 0
}

Handling validation errors

When a write violates the schema, Mongoose throws a ValidationError (name 'ValidationError') or a duplicate-key error (code 11000). Centralize the mapping in an error-handling middleware so routes stay clean.

// registered last, after all routes
app.use((err, req, res, next) => {
  if (err.name === 'ValidationError') {
    const fields = Object.values(err.errors).map((e) => e.message);
    return res.status(400).json({ error: 'Validation failed', fields });
  }
  if (err.code === 11000) {
    return res.status(409).json({ error: 'Duplicate value', keys: err.keyValue });
  }
  res.status(500).json({ error: 'Server error' });
});

Output:

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"error":"Validation failed","fields":["Path `name` is required."]}

Common query reference

MethodPurposeReturns
Model.create(doc)Insert one (or many) documentsSaved document(s)
Model.find(filter)Match many documentsArray (possibly empty)
Model.findById(id)Look up by _idDocument or null
Model.findOne(filter)First matchDocument or null
Model.findByIdAndUpdateUpdate and optionally return new docDocument or null
Model.findByIdAndDeleteRemove by _idDeleted document or null
Model.countDocuments(filter)Count matchesNumber

Warning: find returns [] and findById returns null when nothing matches — neither throws. Always check for null before reading properties, or you will hit a TypeError.

Best Practices

  • Connect once at startup with mongoose.connect; rely on the built-in pool rather than opening connections per request.
  • Define every field with explicit types and validation (required, enum, match) so bad data is rejected before it reaches the database.
  • Pass runValidators: true on findByIdAndUpdate — by default Mongoose skips schema validation on updates.
  • Use { new: true } when you want the updated document back instead of the pre-update version.
  • Funnel ValidationError and duplicate-key (11000) errors through one error-handling middleware for consistent responses.
  • On Express 4, wrap async handlers in a try/catch or async wrapper; on Express 5, thrown rejections forward to the error handler automatically.
  • Add indexes (unique, compound) for fields you query or constrain frequently to keep reads fast.
Last updated June 14, 2026
Was this helpful?