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
npm install mongodbimport { 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()anddb.collection()are cheap handles; they don't open connections themselves.- Call
await client.close()when the process shuts down.
CRUD with async/await
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();findandaggregatereturn cursors: call.toArray(), or iterate withfor 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 checkingObjectId.isValid(id), or the query matches nothing. - A duplicate unique value throws a
MongoServerErrorwithcode === 11000; turn it into a409 Conflictresponse. - Create the indexes your queries need at startup with
createIndexesorcreateIndex; they do nothing if the index already exists. - Transactions use
client.startSession()andsession.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.
CRUD from 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();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
- Line 3The connection string comes from an environment variable, with a local default.
- Line 5Database and collection handles are cheap and don't open connections.
- Lines 15 to 18find returns a cursor; sort and projection work as in mongosh, and toArray collects the results.
- Line 21Results include the same counts as in mongosh.
- Lines 25 to 28Aggregation pipelines are identical to mongosh, followed by toArray.
- Line 31Closing the client releases its connections.
ObjectIds and duplicate keys
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();{ name: 'Ada' }
null
invalid id
not found
409 Conflict: email already registeredLine-by-line explanation
- Line 7A unique index enforces one account per email; it's safe to call at every startup.
- Line 10Ids in URLs are strings, like this one.
- Line 13isValid rejects strings that can't be ObjectIds before querying.
- Line 14The string must be converted to an ObjectId to match the stored _id.
- Line 18Querying with the raw string finds nothing, a very common bug.
- Line 25Duplicate key errors have code 11000, which an API turns into a 409 response.
An Express API with a shared client
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();
});Buy milk string
{"text":"Buy milk"}
400 404Line-by-line explanation
- Lines 4 to 6One client is created at startup and shared by every request.
- Line 13Input is validated before it reaches the database.
- Line 15The new ObjectId is returned to the client, where it becomes a string in JSON.
- Line 20Invalid ids get 400 rather than reaching the database.
- Line 21The id from the URL is converted to an ObjectId for the query.
- Lines 31 to 32The client is closed when the server shuts down.
Common MongoDB Advanced Mistakes
Creating a client per request
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());
});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
await orders.findOne({ _id: req.params.id }); // never matches an ObjectIdawait orders.findOne({ _id: new ObjectId(req.params.id) });Passing user input straight into a filter
await users.findOne({ email: req.body.email, password: req.body.password }); // { "$ne": null } logs in as anyoneif (typeof req.body.email !== 'string') return res.status(400).end();
const user = await users.findOne({ email: req.body.email }); // then verify a password hashMongoDB 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.
Complete the line that collects a cursor's documents into an array.
const docs = await products.find({ price: { $lt: 10 } }).();
How many MongoClient instances should a typical Node.js server create?
Why does findOne({ _id: '64b7f3...' }) return null for an existing document?
Which error code means a duplicate key?
How do you include an operation in a transaction with the driver?
MongoDB Advanced Quiz
5 questions to check what you learned.
Which npm package is MongoDB's official Node.js driver?
- mongo-node
- mongosh
- mongodb
- mongo-driver
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
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 })
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
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