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:

```bash

mkdir express-example

cd express-example

npm init -y

```

The 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:

```bash

npm install express mongodb cors dotenv

npm install nodemon --save-dev

```

This 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=3000

```

It’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:

```javascript

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:

```javascript

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:

```javascript

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:

```javascript

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:

```javascript

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:

```javascript

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:

```javascript

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:

```javascript

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:

```javascript

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.

```javascript

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.

```javascript

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:

```javascript

// 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/{id}
  • PUT /users/{id}
  • DELETE /users/{id}

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:

```bash

node main.js

```

While 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:

```json

"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.

FAQs

1. What are the advantages of using MongoDB with Node.js?

MongoDB’s NoSQL structure complements Node.js’s non-blocking I/O quite well, which helps you to develop high-performance, scalable, and flexible APIs over large datasets.

2. How do I handle errors in Node.js REST APIs?

Using middleware in Express to catch and handle errors globally can ensure that your REST API endpoints have coherent error messages and status codes.

3. Can I deploy my Node Express MongoDB API?

Yes, you can deploy your API on cloud platforms, including AWS, Heroku, or DigitalOcean. Similarly, you can use MongoDB Atlas for that.

Nic Raboy is an advocate of modern development technologies and has hands on experience with several different programming languages such as JavaScript, Golang, and C#, as well as a variety of frameworks such as React and Unity. He spends a lot of his time writing about his development experiences related to making development easier to understand.