MongoDB with Node.js

Connect a Node.js application to MongoDB with the official driver: manage one shared MongoClient, run CRUD queries and aggregations with async/await, work with ObjectId, handle duplicate key errors, run transactions, and build an Express API on top.

Lesson 19 of 43Advanced17 min read

mongosh is perfect for exploring data, but applications talk to MongoDB through a driver. For Node.js, the official mongodb package gives you the same operations you've used throughout this course, insertOne, find, updateMany, aggregate and the rest, as async methods that return promises. If you've followed the Node.js tutorial, you'll feel at home immediately.

The driver manages a connection pool: a set of open connections shared by every request your server handles. You create one MongoClient when the app starts and reuse it everywhere. Creating a client per request is one of the most common performance mistakes, because each one opens new connections.

In this lesson you'll connect with a connection string from an environment variable, run CRUD operations and aggregations, convert string IDs into ObjectIds safely, handle duplicate key errors, create indexes at startup, run a transaction, and put it all together in a small Express API. The query syntax is the same as in mongosh; the main differences are await, toArray() and passing options as objects.

Connecting

Terminal
npm install mongodb
JavaScript
import { MongoClient } from 'mongodb';

const client = new MongoClient(process.env.MONGODB_URI ?? 'mongodb://localhost:27017');
await client.connect();

const db = client.db('shop');
const products = db.collection('products');
  • The connection string contains the host, and for Atlas also the username, password and options: mongodb+srv://user:pass@cluster0.example.mongodb.net/. Keep it in an environment variable, never in code.
  • Create one client at startup and share it; it maintains a pool of connections.
  • client.db() and db.collection() are cheap handles; they don't open connections themselves.
  • Call await client.close() when the process shuts down.

CRUD with async/await

JavaScript
const { insertedId } = await products.insertOne({ name: 'Mug', price: 8.5 });
const cheap = await products.find({ price: { $lt: 10 } }).sort({ price: 1 }).limit(20).toArray();
const one = await products.findOne({ _id: insertedId }, { projection: { name: 1 } });
await products.updateOne({ _id: insertedId }, { $inc: { stock: -1 } });
await products.deleteOne({ _id: insertedId });
const stats = await products.aggregate([{ $group: { _id: null, avg: { $avg: '$price' } } }]).toArray();
  • find and aggregate return cursors: call .toArray(), or iterate with for await (const doc of cursor) for large results.
  • Options that are positional in mongosh are objects in the driver: findOne(filter, { projection }), updateOne(filter, update, { upsert: true }).
  • Results have the same shape as in mongosh: insertedId, matchedCount, modifiedCount, deletedCount.

ObjectIds, errors and indexes

  • IDs arrive in URLs as strings. Convert them with new ObjectId(id), after checking ObjectId.isValid(id), or the query matches nothing.
  • A duplicate unique value throws a MongoServerError with code === 11000; turn it into a 409 Conflict response.
  • Create the indexes your queries need at startup with createIndexes or createIndex; they do nothing if the index already exists.
  • Transactions use client.startSession() and session.withTransaction(async () => { ... }), passing { session } to every operation.

MongoDB Advanced Syntax

Driver essentials

import { MongoClient, ObjectId } from 'mongodb';
const client = new MongoClient(process.env.MONGODB_URI);
const orders = client.db('shop').collection('orders');
await orders.insertOne(doc);
await orders.find({ status: 'paid' }).sort({ createdAt: -1 }).limit(10).toArray();
await orders.findOne({ _id: new ObjectId(id) });
await orders.updateOne({ _id }, { $set: { status: 'shipped' } });
await orders.aggregate(pipeline).toArray();
await orders.createIndex({ customerId: 1, createdAt: -1 });
const session = client.startSession();
await session.withTransaction(async () => { await orders.insertOne(doc, { session }); });
await client.close();

MongoDB Advanced Summary

  • Install mongodb, create one MongoClient from MONGODB_URI, and reuse it across the app.
  • Collection methods mirror mongosh but return promises; find and aggregate return cursors.
  • Convert string ids with new ObjectId(id) after ObjectId.isValid(id).
  • Catch error code 11000 for duplicate keys; create indexes at startup.
  • Transactions use a session and withTransaction, passing { session } to each operation.

MongoDB Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

CRUD from Node.js

Node.js
import { MongoClient } from 'mongodb';

const client = new MongoClient(process.env.MONGODB_URI ?? 'mongodb://localhost:27017');
await client.connect();
const products = client.db('driver_demo').collection('products');
await products.deleteMany({});

const { insertedIds } = await products.insertMany([
  { name: 'Mug', price: 8.5, stock: 12 },
  { name: 'Lamp', price: 24, stock: 0 },
  { name: 'Pen', price: 1.2, stock: 40 },
]);
console.log(`Inserted ${Object.keys(insertedIds).length} products`);

const inStock = await products
  .find({ stock: { $gt: 0 } }, { projection: { _id: 0, name: 1, price: 1 } })
  .sort({ price: -1 })
  .toArray();
console.log(inStock);

const { modifiedCount } = await products.updateOne({ name: 'Lamp' }, { $inc: { stock: 5 } });
const lamp = await products.findOne({ name: 'Lamp' }, { projection: { _id: 0, stock: 1 } });
console.log(modifiedCount, lamp);

const [summary] = await products.aggregate([
  { $group: { _id: null, items: { $sum: '$stock' }, avgPrice: { $avg: '$price' } } },
  { $project: { _id: 0, items: 1, avgPrice: { $round: ['$avgPrice', 2] } } },
]).toArray();
console.log(summary);

await client.close();
Output
Inserted 3 products
[ { name: 'Mug', price: 8.5 }, { name: 'Pen', price: 1.2 } ]
1 { stock: 5 }
{ items: 57, avgPrice: 11.23 }
Line-by-line explanation
  1. Line 3The connection string comes from an environment variable, with a local default.
  2. Line 5Database and collection handles are cheap and don't open connections.
  3. Lines 15 to 18find returns a cursor; sort and projection work as in mongosh, and toArray collects the results.
  4. Line 21Results include the same counts as in mongosh.
  5. Lines 25 to 28Aggregation pipelines are identical to mongosh, followed by toArray.
  6. Line 31Closing the client releases its connections.
Example 2

ObjectIds and duplicate keys

Node.js
import { MongoClient, ObjectId } from 'mongodb';

const client = new MongoClient(process.env.MONGODB_URI ?? 'mongodb://localhost:27017');
await client.connect();
const users = client.db('driver_demo').collection('users');
await users.deleteMany({});
await users.createIndex({ email: 1 }, { unique: true });

const { insertedId } = await users.insertOne({ email: 'ada@example.com', name: 'Ada' });
const idFromUrl = insertedId.toHexString();

async function findUser(id) {
  if (!ObjectId.isValid(id)) return 'invalid id';
  const user = await users.findOne({ _id: new ObjectId(id) }, { projection: { _id: 0, name: 1 } });
  return user ?? 'not found';
}
console.log(await findUser(idFromUrl));
console.log(await users.findOne({ _id: idFromUrl }));
console.log(await findUser('not-an-id'));
console.log(await findUser('64b7f3c2a1b2c3d4e5f60718'));

try {
  await users.insertOne({ email: 'ada@example.com', name: 'Another Ada' });
} catch (err) {
  console.log(err.code === 11000 ? '409 Conflict: email already registered' : err.message);
}
await client.close();
Output
{ name: 'Ada' }
null
invalid id
not found
409 Conflict: email already registered
Line-by-line explanation
  1. Line 7A unique index enforces one account per email; it's safe to call at every startup.
  2. Line 10Ids in URLs are strings, like this one.
  3. Line 13isValid rejects strings that can't be ObjectIds before querying.
  4. Line 14The string must be converted to an ObjectId to match the stored _id.
  5. Line 18Querying with the raw string finds nothing, a very common bug.
  6. Line 25Duplicate key errors have code 11000, which an API turns into a 409 response.
Example 3

An Express API with a shared client

Node.js
import express from 'express';
import { MongoClient, ObjectId } from 'mongodb';

const client = new MongoClient(process.env.MONGODB_URI ?? 'mongodb://localhost:27017');
await client.connect();
const notes = client.db('driver_demo').collection('notes');
await notes.deleteMany({});

const app = express();
app.use(express.json());

app.post('/notes', async (req, res) => {
  if (typeof req.body.text !== 'string' || !req.body.text.trim()) return res.status(400).json({ error: 'text is required' });
  const doc = { text: req.body.text.trim(), createdAt: new Date() };
  const { insertedId } = await notes.insertOne(doc);
  res.status(201).json({ id: insertedId, text: doc.text });
});

app.get('/notes/:id', async (req, res) => {
  if (!ObjectId.isValid(req.params.id)) return res.status(400).json({ error: 'Bad id' });
  const note = await notes.findOne({ _id: new ObjectId(req.params.id) }, { projection: { _id: 0, text: 1 } });
  note ? res.json(note) : res.status(404).json({ error: 'Not found' });
});

const server = app.listen(0, async () => {
  const base = `http://localhost:${server.address().port}/notes`;
  const created = await (await fetch(base, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: ' Buy milk ' }) })).json();
  console.log(created.text, typeof created.id);
  console.log(await (await fetch(`${base}/${created.id}`)).text());
  console.log((await fetch(`${base}/abc`)).status, (await fetch(`${base}/64b7f3c2a1b2c3d4e5f60718`)).status);
  server.close();
  await client.close();
});
Output
Buy milk string
{"text":"Buy milk"}
400 404
Line-by-line explanation
  1. Lines 4 to 6One client is created at startup and shared by every request.
  2. Line 13Input is validated before it reaches the database.
  3. Line 15The new ObjectId is returned to the client, where it becomes a string in JSON.
  4. Line 20Invalid ids get 400 rather than reaching the database.
  5. Line 21The id from the URL is converted to an ObjectId for the query.
  6. Lines 31 to 32The client is closed when the server shuts down.

Common MongoDB Advanced Mistakes

Creating a client per request

Don't
app.get('/products', async (req, res) => {
  const client = await MongoClient.connect(uri);          // new pool on every request
  res.json(await client.db('shop').collection('products').find().toArray());
});
Do
const client = new MongoClient(uri);                       // once, at startup
const products = client.db('shop').collection('products');
app.get('/products', async (req, res) => res.json(await products.find().limit(50).toArray()));

Querying _id with a string

Don't
await orders.findOne({ _id: req.params.id });              // never matches an ObjectId
Do
await orders.findOne({ _id: new ObjectId(req.params.id) });

Passing user input straight into a filter

Don't
await users.findOne({ email: req.body.email, password: req.body.password });   // { "$ne": null } logs in as anyone
Do
if (typeof req.body.email !== 'string') return res.status(400).end();
const user = await users.findOne({ email: req.body.email });                  // then verify a password hash

MongoDB Advanced in Real Projects

The driver in production

Node.js back ends keep a single MongoClient in a db.js module, read MONGODB_URI from the environment, create indexes at startup and close the client on SIGTERM. Serverless functions cache the client between invocations to reuse connections. Teams validate input with Zod before building filters, which also blocks NoSQL injection, and many add Mongoose, covered in the next lesson, for schemas and models.

MongoDB Advanced FAQ

How do I reuse a MongoDB connection in Express?

Create one MongoClient in its own module, call connect() once when the app starts, and export the client or the collections you need. Every route handler then imports and uses the same client, which shares a pool of connections. Creating a new client per request is slow and can exhaust the server's connection limit.

Why does findOneAndUpdate return the document instead of { value }?

Since version 6 of the Node.js driver, findOneAndUpdate, findOneAndReplace and findOneAndDelete return the document directly, or null. Older versions returned an object with a value property, so code written for them reads result.value and gets undefined. Pass { includeResultMetadata: true } if you still want the old shape.

How many connections does the MongoDB Node.js driver open?

Each MongoClient keeps a connection pool per server, with a default maxPoolSize of 100. Connections are created as needed, so a quiet app uses only a few. Change it with an option such as new MongoClient(uri, { maxPoolSize: 20 }), and remember that every running instance of your app has its own pool.

What does MongoServerSelectionError mean?

The driver couldn't find a suitable server to send the operation to before the server selection timeout, 30 seconds by default. Common causes are a wrong host or port, the server not running, a firewall or Atlas IP access list blocking you, or TLS settings that don't match. Check that the same connection string works in mongosh from the same machine.

How do I check whether a string is a valid ObjectId in Node.js?

Call ObjectId.isValid(id) before creating the ObjectId; it returns true for a 24-character hex string such as '65f1c2a0e4b0a1b2c3d4e5f6' and false for something like 'abc'. new ObjectId('abc') throws a BSONError, so validate first or wrap it in try. Return a 400 or 404 response for invalid ids instead of letting the error crash the request.

MongoDB Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the line that collects a cursor's documents into an array.

const docs = await products.find({ price: { $lt: 10 } }).();
Multiple choiceeasy

How many MongoClient instances should a typical Node.js server create?

Multiple choicemedium

Why does findOne({ _id: '64b7f3...' }) return null for an existing document?

Multiple choicemedium

Which error code means a duplicate key?

Multiple choicehard

How do you include an operation in a transaction with the driver?

MongoDB Advanced Quiz

5 questions to check what you learned.

  1. Which npm package is MongoDB's official Node.js driver?

    • mongo-node
    • mongosh
    • mongodb
    • mongo-driver
  2. Where should the connection string live?

    • In the source code
    • In package.json
    • In a public config file
    • In an environment variable such as MONGODB_URI
  3. How do you pass a projection to findOne in the driver?

    • findOne(filter, { projection: { name: 1 } })
    • findOne(filter, { name: 1 })
    • findOne(filter).project()
    • findOne({ projection })
  4. What does ObjectId.isValid(id) check?

    • Whether the document exists
    • Whether the id is unique
    • Whether the string can be turned into an ObjectId
    • Whether the user is allowed
  5. What is NoSQL injection?

    • A slow query
    • User input containing operators, like { "$ne": null }, that changes what a filter matches
    • A missing index
    • A duplicate key