Using the MongoDB Driver
The official mongodb driver is the lowest-level way to talk to MongoDB from Node — no ODM, no schema layer, just direct access to collections and the query API. Understanding it is worthwhile even if you'll use Mongoose later, because Mongoose is built on top of it and many of the same concepts (connections, ObjectId, CRUD methods) show through. This page covers connecting once, the CRUD methods, cursors, and the gotchas the raw driver leaves to you.
Connect once, reuse everywhere
npm install mongodb
db.js — a single shared client
import { MongoClient } from 'mongodb'
const client = new MongoClient(process.env.MONGODB_URI, {
maxPoolSize: 10, // the driver pools connections for you
})
let db
export async function connect() {
if (db) return db
await client.connect() // ONCE at startup
db = client.db('myapp')
return db
}
export const getDb = () => dbGetting a collection
const db = getDb()
const users = db.collection('users') // a handle; collection is created lazilyCreate
// Insert one — returns the generated _id:
const { insertedId } = await users.insertOne({
name: 'Ada', email: 'ada@example.com', createdAt: new Date(),
})
// Insert many:
const { insertedIds } = await users.insertMany([
{ name: 'Bob' }, { name: 'Cleo' },
])Read — `findOne`, `find`, and cursors
import { ObjectId } from 'mongodb'
// One document:
const user = await users.findOne({ email: 'ada@example.com' })
// By _id — MUST convert the string to an ObjectId:
if (!ObjectId.isValid(id)) return res.status(400).json({ error: 'bad id' })
const byId = await users.findOne({ _id: new ObjectId(id) })
// Many — find() returns a CURSOR, not an array. Materialize it:
const adults = await users
.find({ age: { $gte: 18 } })
.sort({ name: 1 })
.limit(20)
.toArray() // ← without this you have a cursor, not docsUpdate
// updateOne with $set — change only the given fields:
const r = await users.updateOne(
{ _id: new ObjectId(id) },
{ $set: { name: 'Ada L.' }, $currentDate: { updatedAt: true } },
)
// r.matchedCount === 0 → no such document (handle as 404)
// r.modifiedCount → how many were actually changed
// Atomically read-and-update, returning the new doc:
const updated = await users.findOneAndUpdate(
{ _id: new ObjectId(id) },
{ $inc: { loginCount: 1 } },
{ returnDocument: 'after' },
)Delete
const r = await users.deleteOne({ _id: new ObjectId(id) })
if (r.deletedCount === 0) return res.status(404).json({ error: 'Not found' })
await users.deleteMany({ status: 'inactive' })NoSQL injection — yes, it exists
// DANGER: a JSON body of { "email": { "$ne": null } } matches ANY user:
const user = await users.findOne({ email: req.body.email, password: req.body.password })
// attacker sends { "email": {"$gt":""}, "password": {"$gt":""} } → logs in as someone
// SAFE: validate that inputs are the expected primitive type first:
if (typeof req.body.email !== 'string') return res.status(400).end()Why people move to Mongoose
The driver gives no schema — every read must defend against any document shape.
You hand-cast
ObjectIds and remember$seton every update.No built-in validation, defaults, middleware/hooks, or relationship population.
Mongoose adds all of that on top of this same driver — at the cost of some overhead and magic.