Building a REST API With Express Framework and MongoDB
Almost every modern web application will need a REST API for the frontend to communicate with, and in almost every scenario, that frontend is going to expect to work with JSON data. As a result, the best development experience will come from a stack that will allow you to use JSON throughout, with no transformations that lead to overly complex code.
Take MongoDB, Express Framework, and Node.js as an example.
Node.js and Express Framework handle your application logic, receiving requests from clients, and sending responses back to them. MongoDB is the database that sits between those requests and responses. In this example, the client can send JSON to the application and the application can send the JSON to the database. The database will respond with JSON and that JSON will be sent back to the client. This works well because MongoDB is a document database that works with BSON, a JSON-like data format.
In this tutorial, we'll see how to create an elegant REST API using MongoDB and Express Framework.
The prerequisites
Prior to starting this tutorial, you'll need a few things in place:
- A MongoDB Atlas cluster, the FREE tier is fine
- Node.js 22+
The expectation is that your MongoDB Atlas cluster has already been provisioned with a username and password, as well as network access rules. If you need help deploying and configuring a cluster, check out the MongoDB Documentation.
We'll be working with an empty project, so to kick things off, you might want to run the following commands:
mkdir express-example
cd express-example
npm init -yThe above commands will create a new project directory and initialize it for Node.js by creating a package.json file.
There are a few dependencies that we need to install. They can be installed by executing the following commands:
npm install express mongodb cors dotenv
npm install nodemon --save-devThis is a vanilla JavaScript project, so no need to worry about TypeScript type definitions. In the above commands, we're installing Express Framework and the MongoDB Node.js Driver, but we're also installing a CORS middleware to allow cross-origin requests and a library to read a .env file, something we'll use to store our configuration variables.
This particular tutorial will use version 7.x of the Node.js Driver for MongoDB.
With the dependencies installed, we should also create each of the project files in preparation of the steps that follow. This particular project will have the following foundation:
- database/connection.js
- routes/users.js
- .env
- main.js
Each file will serve a different purpose that we'll explore throughout the remainder of this tutorial.
Connecting to MongoDB Atlas from Node.js
We're going to define our connection logic to MongoDB as our first step. This means that we'll need to populate our .env file with various configuration variables:
MONGODB_URI=mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/
MONGODB_DATABASE_NAME=express_example
SERVER_PORT=3000It's important that you replace the MONGODB_URI with the connection details that you have obtained from MongoDB Atlas. The MONGODB_DATABASE_NAME is less particular here. If it doesn't exist, it will be automatically created, so just use your own naming conventions.
While we're at it, you can set the SERVER_PORT to whatever you'd like to listen for connections on. The common port for Node.js is port 3000.
With the environment variables in place, open the project's database/connection.js file and include the following code:
const { MongoClient } = require("mongodb");
let client = null;
let db = null;
async function connectToDatabase() {
if(db) {
return db;
}
try {
if (!process.env.MONGODB_URI) {
throw new Error("MONGODB_URI is not set in the environment variables");
}
console.log("Connecting to MongoDB...");
client = new MongoClient(process.env.MONGODB_URI, { appName: "devrel-express-nodejs" });
await client.connect();
console.log("Connected to MongoDB");
db = client.db(process.env.MONGODB_DATABASE_NAME);
return db;
} catch (error) {
console.error("Error connecting to MongoDB", error);
}
}
async function getDatabase() {
if (!db) {
await connectToDatabase();
}
return db;
}
module.exports = { connectToDatabase, getDatabase };The goal with the above code is to create a singleton for our MongoDB connection. This means that we want to maintain a single connection and reuse it throughout, rather than creating multiple for each request.
In the connectToDatabase function, we have the following logic:
async function connectToDatabase() {
if(db) {
return db;
}
try {
if (!process.env.MONGODB_URI) {
throw new Error("MONGODB_URI is not set in the environment variables");
}
console.log("Connecting to MongoDB...");
client = new MongoClient(process.env.MONGODB_URI, { appName: "devrel-express-nodejs" });
await client.connect();
console.log("Connected to MongoDB");
db = client.db(process.env.MONGODB_DATABASE_NAME);
return db;
} catch (error) {
console.error("Error connecting to MongoDB", error);
}
}If the MONGODB_URI variable was not set in the .env file or through other means as an environment variable, we want to throw an error. Otherwise, we can create a MongoClient and attempt to connect. If the connection succeeds, we can return a reference to the database that we wish to use. This is only a reference, the database will not be created until data is inserted.
The connectToDatabase function should be called one time in the main.js file, but we'll see that soon.
The second function, getDatabase, will get the database reference or attempt to connect to MongoDB if there is no reference.
While we're not actually connected to MongoDB at this point, we have the logic in place to establish the connection when starting our Express Framework server.
Configuring the Express Framework Server to listen for connections
With the database ready to go, we can configure Express Framework to start listening for connections. We won't actually create any API endpoints here, only establish various connections.
In the project's main.js file, add the following code:
const express = require("express");
const cors = require("cors");
const dotenv = require("dotenv");
const { connectToDatabase } = require("./database/connection");
dotenv.config();
const app = express();
const port = process.env.SERVER_PORT || 3000;
app.use(express.json());
app.use(cors());
app.listen(port, async () => {
try {
await connectToDatabase();
console.log(`Server is running on port ${port}...`);
} catch (error) {
console.error("Error starting the server", error);
throw error;
}
});In the above code, we configure the "dotenv" dependency to start reading our .env file. We initialize Express Framework, define the port to listen on, and configure the middleware responsible for cross-origin requests and JSON-body payloads.
It's important to note that for this example, CORS is allowing all origins. In a production example, you'd want to harden this middleware and specify who is allowed to make requests against your REST API.
Finally, we have the following:
app.listen(port, async () => {
try {
await connectToDatabase();
console.log(`Server is running on port ${port}...`);
} catch (error) {
console.error("Error starting the server", error);
}
});We're connecting to our MongoDB instance and listening for connections in Express Framework.
There are no API endpoints as of now, but that's next.
Creating API routes for create, read, update, and delete (CRUD) interactions
In a CRUD API, you're going to want at least four API endpoints for any given data model. Take, for example, user information. You're going to want to create a user, obtain a user, update a user, and delete a user. If you add another data model like organization data, you're going to want another selection of endpoints as well, and so on and so forth.
For this example, we're going to stick with just user information, but we're going to take it to five endpoints instead of four.
In the routes/users.js file, add the following code:
const express = require("express");
const { ObjectId } = require("mongodb");
const { getDatabase } = require("../database/connection");
const router = express.Router();
router.get("/", async (request, response) => {
try {
let db = await getDatabase();
let users = await db.collection("users").find({}).toArray();
response.json(users);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});
router.get("/:id", async (request, response) => {
try {
let db = await getDatabase();
let user = await db.collection("users").findOne({ _id: new ObjectId(request.params.id) });
if (!user) {
return response.status(404).json({ error: "User not found" });
}
response.json(user);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});
router.post("/", async (request, response) => {
try {
let db = await getDatabase();
let payload = {
name: request.body.name,
email: request.body.email,
createdAt: new Date(),
}
let user = await db.collection("users").insertOne(payload);
response.status(201).json(user);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});
router.put("/:id", async (request, response) => {
try {
let db = await getDatabase();
let payload = {
updatedAt: new Date()
};
if (request.body.name) payload.name = request.body.name;
if (request.body.email) payload.email = request.body.email;
let user = await db.collection("users").updateOne(
{ _id: new ObjectId(request.params.id) },
{ $set: payload }
);
response.json(user);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});
router.delete("/:id", async (request, response) => {
try {
let db = await getDatabase();
let result = await db.collection("users").deleteOne({ _id: new ObjectId(request.params.id) });
response.json(result);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});
module.exports = router;Let's break down each of the endpoints found above.
Creating data within the application
The first endpoint we'll look at will be responsible for creating data in our MongoDB collection:
router.post("/", async (request, response) => {
try {
let db = await getDatabase();
let payload = {
name: request.body.name,
email: request.body.email,
createdAt: new Date(),
}
let user = await db.collection("users").insertOne(payload);
response.status(201).json(user);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});The above endpoint is accessed through the POST request type.
We get the reference to our database and define the acceptable request payload. A user can pass anything to this endpoint, so we should have at least simple validation in place to keep only the fields we want. In this case, we are accepting a name and email in the request payload and we are snapshotting the current date for our createdAt field. Any other data will be ignored.
With the custom payload, we can use the insertOne operator on the users collection. You can choose to rename this collection or even include it in your .env file.
If successful, the data will show up in MongoDB. In this scenario, MongoDB will create an _id field for us with an ObjectId. Information about the insert operation is returned to the client that requested it.
It's worth noting that you'll probably want more thorough data validation in production. You can use libraries like Zod to do this as well as make use of MongoDB's optional schema validation.
Reading all data in the collection
The next thing we can do is focus on the endpoint for reading data from the collection:
router.get("/", async (request, response) => {
try {
let db = await getDatabase();
let users = await db.collection("users").find({}).toArray();
response.json(users);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});In the above example, we are using a find operator with an empty object. This means that we have no particular filter criteria and all documents in the collection will be returned. Instead of working with a cursor, we are reading all those documents into an array.
If we wanted to provide a filter, it would allow us to narrow the results.
Reading specific data in the collection
Speaking of narrowing results, let's add an endpoint for finding a particular document in our collection:
router.get("/:id", async (request, response) => {
try {
let db = await getDatabase();
let user = await db.collection("users").findOne({ _id: new ObjectId(request.params.id) });
response.json(user);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});Instead of using find, we are using findOne. These two operators can be used the same, but one will only return a single result. In this example, we are taking an id string, filtering for it in our collection. Because default _id fields in MongoDB use an ObjectId, we have to convert the string into an ObjectId for our filter to work.
Updating data in the collection
To update data in MongoDB, we will do a little extra. Take the following endpoint:
router.put("/:id", async (request, response) => {
try {
let db = await getDatabase();
let payload = {
updatedAt: new Date()
};
if (request.body.name) payload.name = request.body.name;
if (request.body.email) payload.email = request.body.email;
let user = await db.collection("users").updateOne(
{ _id: new ObjectId(request.params.id) },
{ $set: payload }
);
response.json(user);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});In the above endpoint, we are expecting a PUT request.
We will do some basic validation on the fields that were passed in the request payload. In this case, we always provide an application generated updatedAt field, but we check to see if the client has passed a name and an email in the request body. If they have, add it to the payload object. Otherwise, ignore it. We're doing this because of how we plan to use the updateOne operator.
let user = await db.collection("users").updateOne(
{ _id: new ObjectId(request.params.id) },
{ $set: payload }
);In the updateOne operator, the first object is the filter criteria, just like what we saw in the find and findOne operators. The next object is the change criteria, or what we're changing and how.
The $set operator will create or replace any field that shows up in the provided object. This is why we don't want to allow all fields, but we also don't want to add null fields because those will also be changed. In this example, we want to be very specific in our payload object about what is being changed.
Deleting data in the collection
The final endpoint will be for deleting data from the collection.
router.delete("/:id", async (request, response) => {
try {
let db = await getDatabase();
let result = await db.collection("users").deleteOne({ _id: new ObjectId(request.params.id) });
response.json(result);
} catch (error) {
response.status(500).json({ error: "Internal server error" });
}
});The deleteOne operator accepts a filter, just like what we've seen in the find, findOne, and updateOne operators so far.
In this example, we are choosing to delete only a single document that matches the id that the client passes.
Adding the API routes to the Express Framework server
We created our API routes, but they cannot be accessed in a client request. To do this, we need to tell our server where to look.
In the main.js file, add the following:
// Previous imports here...
const usersRouter = require("./routes/users");
// Previous configuration here...
app.use("/users", usersRouter);
// app.listen(port, async () => {});We're only adding two lines to our main.js file. We need to import the router from our routes/users.js file and we need to tell Express Framework to use it.
The API endpoints we had just created will have a "/users" prefix. In other words, our API endpoints will look like the following:
- POST /users/
- GET /users/
- GET /users/
- PUT /users/
- DELETE /users/
You can opt to change the prefix or remove the prefix in the main.js file, but it's a good idea to have a useful naming convention.
Running the REST API with various tips and tricks
It's time to attempt to run the application. From your command line, you can execute the following:
node main.jsWhile the above command works great, for development, you may want to take advantage of the "nodemon" tool and your project's package.json file.
Add the following to your package.json file:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node main.js",
"dev": "nodemon main.js"
},We're looking at the dev script in particular. With it, we can run npm run dev which will start our server, but any time we make a change to our code, it will automatically reload. This saves us the time of constantly starting and stopping our server every time we want to see a change in action.
Conclusion
You just saw how to create a REST API with Express Framework and Node.js as the logic layer, and MongoDB as the database layer. The combination of these technologies is particularly important because you're working with JSON data throughout the application. The client sends JSON data to the API, that JSON data is sent directly to MongoDB, and the JSON response that comes from MongoDB is sent right back to the client. No need to transform the data at any point in the process, making development quick and easy.
This content first appeared on Hevo.

Nic Raboy
Nic Raboy is an advocate of modern web and mobile development technologies. He has experience in C#, JavaScript, Golang and a variety of frameworks such as Angular, NativeScript, and Unity. Nic writes about his development experiences related to making web and mobile development easier to understand.
Search
Recent Posts
- Building a REST API With Express Framework and MongoDB
- Build a Real-Time Voice Interview Coach with TypeScript and LiveKit
- Introducing CFP Manager to Manage Speaking Engagements for the Team
- Using Dot Notation to Query Nested Fields in MongoDB
- Build a Movie Watchlist with Node.js, TypeScript, and MongoDB