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_idis anObjectId, not a string. Always coerce withnew ObjectId(id)— and catch theBSONErrorit 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 overhead | You want enforced schemas and validation |
| Documents are genuinely flexible/schemaless | You rely on hooks, virtuals, or populate |
| You need the newest server features first | You prefer model classes and TypeScript types |
| Performance is critical and you write tight queries | Team 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
MongoClientat startup and reuse the shared client; never connect per request. - Cache the
dbhandle and derive collections from it — collection lookups are cheap and synchronous. - Always wrap
_idstrings innew ObjectId()and handle theBSONErrorthrown on bad input. - Use update operators (
$set,$inc) rather than replacing whole documents to avoid clobbering fields. - Check
matchedCount/deletedCountto return accurate 404s instead of silent no-ops. - Tune
maxPoolSizeto your cluster’s connection budget and the number of app instances. - Call
client.close()onSIGTERMso the pool drains cleanly on shutdown.