NodeJSUsing the MongoDB Driver

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

Bash
npm install mongodb

db.js — a single shared client

JS
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 = () => db
Create ONE `MongoClient` for the whole app — don't connect per request
`MongoClient` maintains an internal **connection pool**; you create it once at startup and reuse it for every query. Calling `new MongoClient()`/`connect()` per request defeats pooling, leaks connections, and exhausts the server's limit — the same rule as every database ([connection pooling](/nodejs/connection-pooling)). Connect during boot, store the `db` handle, and import it everywhere.
Getting a collection

JS
const db = getDb()
const users = db.collection('users')   // a handle; collection is created lazily
Create

JS
// 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

JS
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 docs
`find()` returns a cursor — `await find(...)` is NOT your documents
A frequent beginner bug: `const docs = await users.find(query)` gives you a **cursor object**, not the results. You must consume it — `.toArray()` to get all (only when the result set is bounded), or iterate with `for await (const doc of cursor)` for large sets to avoid loading everything into memory. `findOne`, by contrast, returns the document directly.
Validate/convert `ObjectId` or risk a thrown error
`new ObjectId(str)` throws if `str` isn't a valid 24-char hex string — an attacker sending `/users/abc` can crash an unguarded handler. Always `ObjectId.isValid(id)` first and respond `400` on failure. This casting is exactly what [Mongoose](/nodejs/mongoose) automates; with the raw driver it's your job.
Update

JS
// 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' },
)
Update needs `$set` — passing a bare object REPLACES the document
`updateOne(filter, { name: 'X' })` (without an operator) **replaces the entire document** with `{ name: 'X' }`, wiping every other field. To change specific fields, wrap them in `$set`. Use the [update operators](/nodejs/mongodb-intro) (`$set`, `$inc`, `$push`, `$pull`) deliberately — a missing `$set` is a classic, data-destroying mistake.
Delete

JS
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' })
Check the `*Count` fields to detect 'not found'
The driver doesn't throw when nothing matches — it returns counts. Check `matchedCount`/`deletedCount` to distinguish "updated/deleted" from "no such document" and respond [404](/nodejs/api-error-responses) accordingly. Silently treating a no-match as success hides bugs.
NoSQL injection — yes, it exists

JS
// 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()
NoSQL injection: reject query-operator objects in user input
MongoDB queries are objects, so if you drop `req.body` straight into a filter, an attacker can send `{ "$ne": null }` or `{ "$gt": "" }` where you expected a string — turning your equality check into "match anything." Defend by **validating types** ([Zod](/nodejs/zod)/[Joi](/nodejs/joi)) so each field is the primitive you expect, and never spread raw request bodies into queries. Sanitizers like `express-mongo-sanitize` strip `$`-keys as defense-in-depth.
Why people move to Mongoose
  • The driver gives no schema — every read must defend against any document shape.

  • You hand-cast ObjectIds and remember $set on 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.

Next
Add schemas, validation, and modeling on top of the driver: [Mongoose ODM](/nodejs/mongoose).