Skip to content
Express.js ex data 5 min read

MongoDB Native Driver

Mongoose is the default way most Express apps talk to MongoDB, but it isn’t the only way — and sometimes its schema layer is overhead you don’t want. The official mongodb package is the native driver that Mongoose itself is built on. Using it directly gives you full control over BSON documents, the freshest server features, and zero abstraction tax. This page shows how to connect with MongoClient, access collections, run the full CRUD cycle, and decide when the raw driver beats reaching for an ODM.

Installing and connecting with MongoClient

Install the single official package — no ODM required.

npm install mongodb

The driver exposes a MongoClient that manages an internal connection pool. As with any database in Express, you connect once at startup and share the resulting client across every request rather than reconnecting per call. A small module that connects and caches the database handle keeps the rest of the app clean.

// db.js — connect once, reuse everywhere
import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI, {
  maxPoolSize: 10,
});

let db;

export async function connectDB() {
  if (db) return db;
  await client.connect();
  db = client.db("shop"); // database name
  console.log("Connected to MongoDB");
  return db;
}

export function getDB() {
  if (!db) throw new Error("DB not initialized — call connectDB() first");
  return db;
}

export { client };

Call connectDB() before you start listening so the pool is warm and any connection error fails fast.

// app.js
import express from "express";
import { connectDB, client } from "./db.js";
import productsRouter from "./routes/products.js";

const app = express();
app.use(express.json());
app.use("/products", productsRouter);

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "Internal Server Error" });
});

const start = async () => {
  await connectDB();
  app.listen(3000, () => console.log("Listening on :3000"));
};

start();

process.on("SIGTERM", async () => {
  await client.close(); // drain the pool on shutdown
  process.exit(0);
});

Accessing collections

A collection is the Mongo equivalent of a SQL table — a group of documents. You get one by name from the database handle. Collections are created lazily on first write, so you never need a migration step just to start inserting.

import { getDB } from "../db.js";

const products = () => getDB().collection("products");

Inserting documents

insertOne and insertMany add documents. Mongo assigns an _id of type ObjectId automatically and returns it in the result, so you rarely set ids yourself.

// routes/products.js
import { Router } from "express";
import { ObjectId } from "mongodb";
import { getDB } from "../db.js";

const router = Router();
const col = () => getDB().collection("products");

router.post("/", async (req, res, next) => {
  try {
    const result = await col().insertOne({
      name: req.body.name,
      price: req.body.price,
      tags: req.body.tags ?? [],
      createdAt: new Date(),
    });
    res.status(201).json({ _id: result.insertedId });
  } catch (err) {
    next(err);
  }
});

A POST /products with { "name": "Keyboard", "price": 79 } responds:

Output:

{
  "_id": "665f1a9c2e4b1f0012a3c4d5"
}

Finding documents

findOne returns a single document or null; find returns a cursor you turn into an array with .toArray(). Filters are plain objects, and you can chain .sort(), .limit(), and .skip() for pagination. To look up by _id, wrap the string in ObjectId.

router.get("/", async (req, res, next) => {
  try {
    const list = await col()
      .find({ price: { $lte: 100 } })
      .sort({ createdAt: -1 })
      .limit(20)
      .toArray();
    res.json(list);
  } catch (err) {
    next(err);
  }
});

router.get("/:id", async (req, res, next) => {
  try {
    const doc = await col().findOne({ _id: new ObjectId(req.params.id) });
    if (!doc) return res.status(404).json({ error: "Not found" });
    res.json(doc);
  } catch (err) {
    next(err);
  }
});

A common gotcha: querying { _id: req.params.id } with a raw string silently matches nothing, because _id is an ObjectId, not a string. Always coerce with new ObjectId(id) — and catch the BSONError it throws on malformed input.

Updating documents

Use updateOne with operators like $set, $inc, or $push. The result reports matchedCount and modifiedCount so you can detect a missing document. findOneAndUpdate updates and returns the document in one round trip.

router.patch("/:id", async (req, res, next) => {
  try {
    const result = await col().updateOne(
      { _id: new ObjectId(req.params.id) },
      { $set: { price: req.body.price } }
    );
    if (result.matchedCount === 0)
      return res.status(404).json({ error: "Not found" });
    res.json({ modified: result.modifiedCount });
  } catch (err) {
    next(err);
  }
});

Deleting documents

deleteOne removes the first match and returns deletedCount; deleteMany removes every match.

router.delete("/:id", async (req, res, next) => {
  try {
    const result = await col().deleteOne({ _id: new ObjectId(req.params.id) });
    if (result.deletedCount === 0)
      return res.status(404).json({ error: "Not found" });
    res.status(204).end();
  } catch (err) {
    next(err);
  }
});

export default router;

On Express 4.x these try/catch blocks are required so rejected promises reach the error middleware; Express 5.x forwards async rejections automatically and lets you drop the boilerplate.

When to skip Mongoose for the raw driver

Mongoose adds schemas, validation, middleware hooks, and population. That’s genuinely useful — but it’s also weight you may not need.

Reach for the native driver when…Reach for Mongoose when…
You want minimal dependencies and overheadYou want enforced schemas and validation
Documents are genuinely flexible/schemalessYou rely on hooks, virtuals, or populate
You need the newest server features firstYou prefer model classes and TypeScript types
Performance is critical and you write tight queriesTeam familiarity with Mongoose matters

The driver also pairs well with scripts, data pipelines, and aggregation-heavy services where Mongoose’s model layer just gets in the way.

Best Practices

  • Connect once with MongoClient at startup and reuse the shared client; never connect per request.
  • Cache the db handle and derive collections from it — collection lookups are cheap and synchronous.
  • Always wrap _id strings in new ObjectId() and handle the BSONError thrown on bad input.
  • Use update operators ($set, $inc) rather than replacing whole documents to avoid clobbering fields.
  • Check matchedCount/deletedCount to return accurate 404s instead of silent no-ops.
  • Tune maxPoolSize to your cluster’s connection budget and the number of app instances.
  • Call client.close() on SIGTERM so the pool drains cleanly on shutdown.
Last updated June 14, 2026
Was this helpful?