DEV Community

Cover image for How I Integrated a MongoDB Database into my React.js Project
Ethan Groene
Ethan Groene

Posted on • Originally published at Medium

How I Integrated a MongoDB Database into my React.js Project

Originally published to Medium for JavaScript in Plain English on October 30, 2024

Today is the day you bid adieu to being a half-ass developer (pardon my French).

If you are a front-end developer, I’m sure you have daydreamed about what it would be like to be able to not only work with other people’s APIs, but to be able to set up & integrate your own as well.

I’m here to tell you today that it’s not as difficult as it may seem, though it may take a while to familiarize yourself with the concepts behind your code. You may be able to carve out a nice career for yourself working on only one of the two ends, but, if you have SaaS ideas, or want to build a business off your apps, it would be really cool to know how to build out both ends.

Full disclosure; I don’t know everything there is to know about the backend, but I feel confident enough to explain a solution I found for a portfolio project I’m working on. It is not my way or the high way, so if you have found other ways to set it up, I would love it if you explained it in a comment. Also, this article does not cover how to deploy a full-stack app, just a way you can set up a backend that you can work with during development. In reality, there wouldn’t be too much more to do to deploy it, but I plan to write a separate article on how to do that, when I have the time.

As a JavaScript guy, Node.js was the quickest way for me to get set up with the backend, so if you plan to follow my solution, make sure Node.js is installed on your device. My solution will require you to install the Node packages Mongoose, Express, dotenv, cors, & Nodemon. I will explain these shortly. I also assume that you have already set up a MongoDB Atlas account & deployed a cluster; if you haven’t, click here to learn how to do that. To implement my solution, you will need your MongoDB URI.

To provide some context before we get into the steps, I am creating a site that allows users to create, delete, edit, RSVP to, & invite others to events, as well as interact with other users by messages. So far, I have created collections in the project’s MongoDB database that store data related to users & events. I will eventually need another one for messages, but I haven’t gotten to that point in my project yet. For the demos, I will be showing things related only to the users collection.


1. Create a ‘backend’ folder at the root of your project, then cd into it

Image description

After creating the backend folder, set it as the current directory by typing cd backend into the terminal & hitting ‘Enter’.

2. Install necessary Node packages
Make sure the current directory is the backend folder you’ve just created, then install the packages as follows:

Mongoose

npm i mongoose
Enter fullscreen mode Exit fullscreen mode

Mongoose is what’s called an ‘object-modeling tool’, a kind of middleman between your code & your database. It manages relationships between data, provides schema validation (kind of like checking for types with TypeScript), & translates between objects in code & the representation of those objects in MongoDB.

Mongoose Docs

Mongoose NPM page

Express

npm i express
Enter fullscreen mode Exit fullscreen mode

Express.js is a framework that makes it easy to set up APIs by using JavaScript on the server side.

Express.js Docs

Express.js NPM page

dotenv

npm i dotenv
Enter fullscreen mode Exit fullscreen mode

dotenv is a zero-dependency module that makes it possible to load variables that you may want to keep private (like your MongoDB URI) stored in the .env file into process.env, which can be applied, in this case, throughout the backend folder. You should add the .env file to your .gitignore.

dotenv Docs

dotenv NPM page

Nodemon

npm i nodemon
Enter fullscreen mode Exit fullscreen mode

Nodemon is a tool that automatically refreshes the Node application whenever changes are made in the files. It’s a wonderful thing, unless you would prefer closing & restarting the server after you make changes to files (/sarcasm ;) ).

cors

npm i cors
Enter fullscreen mode Exit fullscreen mode

This package is used to enable Cross-Origin Resource Sharing (CORS). This will help you avoid getting an error in the console that reads something like “Access to fetch at from origin has been blocked by CORS policy…”, which occurs because the Same Origin Policy prevents the response from being received, due to the originating & receiving domains being different due to the port number. This may cause your page to not display at all, or to display in a way you hadn’t planned.

cors NPM page

3. Create the server file
At the root of the backend directory, create a server.cjs file. In it, we need to following code (please read the comments explaining everything I’m doing):

// Import & enable dotenv in the file:
require("dotenv").config();

// Express app:
const express = require("express");
const app = express();

// Import Mongoose:
const mongoose = require("mongoose");

// Import & apply cors package:
const cors = require("cors");
app.use(cors()); // more on .use() method below...

/* Assign routes (which we will later define in respective files), 
  to variables (in my app so far, : */
const userRoutes = require("./routes/users.cjs");
const eventRoutes = require("./routes/events.cjs");

// MIDDLEWARE

/* .use() is a method on an Express app. It mounts the specified 
middleware function(s) at the specified path: the middleware 
function is executed when the base of the requested path matches path */

/* If any request has data that it sends to the server, 
   the code on the next line attaches it to request object, 
   so we can access it in request handler */
app.use(express.json());

/* Whenever an HTTP request is made to a certain endpoint/path,
   log the path & type of request to console (seen in terminal) */
app.use((req, res, next) => {
  console.log(req.path, req.method);
  next();
});

// ROUTES
// Attach defined routes to their corresponding endpoints:
app.use("/palz/users", userRoutes);
// app.use("/palz/events", eventRoutes);
// app.use("/palz/messages", messageRoutes);

// Connect to Mongoose:
mongoose
  .connect(process.env.MONGODB_URI)
  .then(() => {
    // Listen for requests only after connecting to DB:
    app.listen(process.env.PORT, () => {
      console.log(`Connected to DB & listening on port ${process.env.PORT}!`);
    });
  })
  // If there's an error connecting, we will see that in the terminal:
  .catch((error) => console.log(error));
Enter fullscreen mode Exit fullscreen mode

You can see I used some variables from my .env file above. Here’s what my .env file looks like so far (with top-secret info redacted):

Image description

‘.cjs’ means ‘common JavaScript’, which is used in server-side environments, like Node.js. I think it helped eliminate trivial type errors as opposed to using the .js extension, but I don’t quite remember. It has worked just fine, so I haven’t dived too deeply into it. I wouldn’t make too big a fuss over the file extension here if I were you.

4. Set up data models
To ensure any data that gets sent to the database fits the defined shape, we need to define a model that each document sent to a collection in the database will need to fit. Mongoose can help with this. If data we try to send to the database doesn’t match the specified shape, it will result in a bad request, and nothing will be added to the database.

Let’s set up a data model for the documents in the /users collection. We’ll do this in userModel.js in folder models, which should be at the root of the backend directory. Below is only the user data model, but you can see all model files here.

const mongoose = require("mongoose");

const Schema = mongoose.Schema;

// Create a new instance of mongoose.Schema, assign to 'userSchema' variable:
// Each user document in DB should fit this schema
const userSchema = new Schema({
  firstName: {
    type: String,
    required: true,
  },
  lastName: {
    type: String,
    required: true,
  },
  password: {
    type: String,
    required: true,
  },
  city: {
    type: String,
    required: false,
  },
  stateProvince: {
    type: String,
    required: false,
  },
  country: {
    type: String,
    required: false,
  },
  phoneCountry: {
    type: String,
    required: false,
  },
  phoneCountryCode: {
    type: String,
    required: false,
  },
  phoneNumberWithoutCountryCode: {
    type: String,
    required: false,
  },
  instagram: {
    type: String,
    required: false,
  },
  facebook: {
    type: String,
    required: false,
  },
  x: {
    type: String,
    required: false,
  },
  interests: {
    type: Array,
    required: true,
  },
  about: {
    type: String,
    required: false,
  },
  friendRequestsReceived: {
    type: Array,
    required: true,
  },
  friendRequestsSent: {
    type: Array,
    required: true,
  },
  hostingCredits: {
    type: Number,
    required: true,
  },
  profileImage: {
    type: String,
    required: false,
  },
  profileVisibleTo: {
    type: String,
    required: true,
  },
  subscriptionType: {
    type: String,
    required: false,
  },
  whoCanAddUserAsOrganizer: {
    type: String,
    required: true,
  },
  whoCanInviteUser: {
    type: String,
    required: true,
  },
  username: {
    type: String,
    required: true,
  },
  friends: {
    type: Array,
    required: true,
  },
  emailAddress: {
    type: String,
    required: true,
  },
});

// Export, so that schema can be used in other files
module.exports = mongoose.model("User", userSchema);
Enter fullscreen mode Exit fullscreen mode

Certain properties on a user object, like about, will be initialized to an empty string when a new user document is sent to the database, so they should not be required in the schema, else a bad request will result. For the sake of brevity, I won’t show here my eventModel.js file, but it would look very similar to the user model above, just with different properties & specifications on those properties. You can see that file here.

5. Set up controllers
At the root of the backend directory, I created a controllers folder, which will store a controllers file for each endpoint. Controllers essentially handle HTTP CRUD requests made to the endpoint. You should only create as many as are needed for your situation, but for this one involving users, I will need 5.

As the handlers will return a Promise, we’ll need to handle requests that succeed & ones that fail. Again, I will only show you my userControllers.js file for the sake of brevity, but you can see all my controllers files here. Please see the comments in the code below to understand what I’m doing to set up these handlers:

// Import Mongoose:
const mongoose = require("mongoose");

// Import data model for user objects:
const User = require("../models/userModel");

// get all users:
const getAllUsers = async (req, res) => {
  // use .find() method on User data schema to fetch all user objects:
  // leave obj in .find() blank, as all users are being fetched
  const allUsers = await User.find({});

  /* Set HTTP status to 200 (ok) & convert response to JSON. The result is
  an object in JSON that contains info on all users */
  res.status(200).json(allUsers);
};

/* get a single user by their id (unique property added to each user object
   upon its creation in MongoDB */
const getUser = async (req, res) => {
  // Get id from request parameters:
  const { id } = req.params;

  /* If id is not a valid MongoDB ObjectId, set HTTP status to 400 (bad 
     request) and return error message in JSON form */
  if (!mongoose.Types.ObjectId.isValid(id)) {
     return res.status(400).json({ error: "Bad request (invalid id)" });
  }

  /* assign user to document in DB that has id that matches the 
     id defined in this method: */
  const user = await User.findById(id);

  /* If no user id in database matches id from the request parameter,
     set HTTP status to 404 and return error message in JSON form */
  if (!user) {
    return res.status(404).json({ error: "User doesn't exist" });
  }

  /* If a user object (document) has an id that matches id in the 
     request parameters, set HTTP status to 200 & return that user object in
     JSON format */
  res.status(200).json(user);
};

// create new user
const createNewUser = async (req, res) => {
  // Destructure all user-object properties from request body:
  const {
    firstName,
    lastName,
    password,
    city,
    stateProvince,
    country,
    phoneCountry,
    phoneCountryCode,
    phoneCountryWithoutCountryCode,
    instagram,
    facebook,
    x,
    interests,
    about,
    friendRequestsReceived,
    friendRequestsSent,
    hostingCredits,
    profileImage,
    profileVisibleTo,
    subscriptionType,
    whoCanAddUserAsOrganizer,
    whoCanInviteUser,
    username,
    friends,
    emailAddress,
  } = req.body;

  // Try creating new user document in users collection in DB
  /* If successful, set HTTP status to 200 & return the newly created user
     document in JSON format. If it fails, set HTTP status to 400 
     (bad request) & return error message in JSON format. */
  try {
    const user = await User.create({
      firstName,
      lastName,
      password,
      city,
      stateProvince,
      country,
      phoneCountry,
      phoneCountryCode,
      phoneCountryWithoutCountryCode,
      instagram,
      facebook,
      x,
      interests,
      about,
      friendRequestsReceived,
      friendRequestsSent,
      hostingCredits,
      profileImage,
      profileVisibleTo,
      subscriptionType,
      whoCanAddUserAsOrganizer,
      whoCanInviteUser,
      username,
      friends,
      emailAddress,
    });
    res.status(200).json(user);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
};

// Delete a user:
const deleteUser = async (req, res) => {
  // Get user document from request parameters by its id:
  const { id } = req.params;

  /* If id is not a valid MongoDB ObjectId, set HTTP status to 400 (bad 
     request) and return error message in JSON form */
  if (!mongoose.Types.ObjectId.isValid(id)) {
     return res.status(400).json({ error: "Bad request (invalid id)" });
  }

  /* Use the findOneAndDelete() method to delete user document with
     id that matches id in request parameters */
  const user = await User.findOneAndDelete({ _id: id });

  /* If no user id in database matches id from the request parameter,
     set HTTP status to 404 and return error message in JSON form */
  if (!user) {
    return res.status(404).json({ error: "User doesn't exist" });
  }

  /* If a user object (document) has an id that matches id in the 
     request parameters, set HTTP status to 200 & return that user object in
     JSON format. The user document will no longer exist in the DB. */
  res.status(200).json(user);
};

// Update a user document:
const updateUser = async (req, res) => {
  // Get user document from request parameters by its id:
  const { id } = req.params;

  /* If id is not a valid MongoDB ObjectId, set HTTP status to 400 (bad 
     request) and return error message in JSON form */
  if (!mongoose.Types.ObjectId.isValid(id)) {
     return res.status(400).json({ error: "Bad request (invalid id)" });
  }

  /* Use the .findOneAndUpdate() method to get a particular user document,
     then update its values. */
  /* 2nd argument in .findOneAndUpdate() is an object containing the 
     properties to be updated, along with their updated values */
  /* 3rd argument will return the updated user object after a 
     successful request */
  const user = await User.findOneAndUpdate({ _id: id }, { ...req.body }, 
    { new: true });

  /* If no user id in database matches id from the request parameter,
     set HTTP status to 404 and return error message in JSON form */
  if (!user) {
    return res.status(404).json({ error: "User doesn't exist" });
  }

  /* If a user object (document) has an id that matches id in the 
     request parameters, set HTTP status to 200 & return that user object in
     JSON format. The updated version of the user document 
     will now be in the place of the old version in the DB. */
  res.status(200).json(user);
};

// Export the controllers:
module.exports = {
  createNewUser,
  getAllUsers,
  getUser,
  deleteUser,
  updateUser,
};
Enter fullscreen mode Exit fullscreen mode

6. Set up routes, test HTTP CRUD requests
Remember userRoutes & eventRoutes that we imported into the server.cjs file? Those come from info in the users.cjs & events.cjs files, which I put inside the routes folder at the root of the backend directory. In a routes file, we need to import the HTTP CRUD request handlers & assign them to run in the appropriate request type & on a certain path.

As before, I will only show what the file that has to do with user, users.js, looks like, but you can see all route files here.

// Import Express:
const express = require("express");

// Import HTTP-CRUD-request handlers:
const {
  createNewUser,
  getAllUsers,
  getUser,
  deleteUser,
  updateUser,
} = require("../controllers/userControllers");

// Assign express.Router() to variable 'router':
const router = express.Router();

// Assign handlers to run on certain requests to router on certain paths:

// GET all users
router.get("/", getAllUsers);

// GET single user:
router.get("/:id", getUser);

// POST (create) a new user:
router.post("/", createNewUser);

// DELETE a user:
router.delete("/:id", deleteUser);

// PATCH (update) a user:
router.patch("/:id", updateUser);

// Export router:
module.exports = router;
Enter fullscreen mode Exit fullscreen mode

Now, it’s time to test out our requests made to the database. You will first need to start the server we just set up. I have the scripts in my package.json file in my backend directory set up like so:

Image description

You should now be able to start up the server in the terminal (make sure the current directory is the backend folder) by running npm run dev. If successful, you should see the message “Connected to DB & listening on port 4000!”. If there is an error, it is likely due to a syntax error somewhere, or because you are trying to access your MongoDB cluster from an IP address that isn’t whitelisted (even if you allow access from any IP address, a VPN may cause this error to occur).

Once you have completed the steps above, and you have the server up & running, you can try out your HTTP CRUD requests using something like Postman, which is super straightforward. If you need a body in a request, add one under the ‘Body’ header, where you should select ‘raw’, then select JSON format (not Text or anything else). Add properties & their values should be inside an object & in JSON format. We have set up error handling as such, that, if there is an erroneous request made, you should be able to figure out what is wrong rather quickly.

7. Build HTTP-CRUD-request handler functions to be called on the frontend
Now comes the part you’ve been waiting for; making it possible to make HTTP CRUD requests upon triggers we define in the frontend, like maybe in a React useEffect or upon certain events the user triggers on the interface. I hope you’re as excited as I am.

As mentioned, these request functions can be called in various places on the frontend, and, like any other function, we can pass in parameters to them, such as a user id or an object containing updated values of properties on a user object. It is often a good idea to call them inside a handler function, as some other things may need to happen at the same time these request functions are called.

If you tested out the requests in Postman, you may have noticed the ‘Code’ button off to the right side. If you click on that, you’ll see the JS fetch option. You can simply copy this code & use it to create a request function. I adapted mine a bit from the code Postman spit out, so that my request functions return something of type Promise<Response>, to which I apply a chain of .then(), .catch(), and maybe a .finally() at the end when I call the request functions somewhere in the app, usually in a handler function. Here is a list containing examples of GET, POST, PATCH, and DELETE requests related to the users/ collection in the database, along with an example of them being used in a handler function:

Get all users

// Request function:
// Later added to an object named 'Requests', which is then default exported
const getAllUsers = (): Promise<TUser[]> => {
  return fetch("http://localhost:4000/palz/users", {
    method: "GET",
    redirect: "follow",
  }).then((response) => response.json() as Promise<TUser[]>);
};

/* Implementing getAllUsers() in a handler which sets allUsers state value
   to what the getAllUsers() returns: */
/* I call this when app initially loads & whenever a change is 
   made to the allUsers state value (called inside a useEffect()
   w/ allUsers in its dependency array */
const fetchAllUsers = (): Promise<void> => Requests.getAllUsers().then(setAllUsers);
Enter fullscreen mode Exit fullscreen mode

Create new user

// Request function:
/* Notice how newUserData (an object of type TUser) is passed in. 
   Certain values from that object are used to set initial values of some
   properties of new user's object, seen in 'raw'. 'raw' is then set
   as the body of the POST request. */
const createUser = (newUserData: TUser): Promise<Response> => {
  const myHeaders = new Headers();
  myHeaders.append("Content-Type", "application/json");

  const raw = JSON.stringify({
    "firstName": newUserData.firstName,
    "lastName": newUserData.lastName,
    "username": newUserData.username,
    "password": newUserData.password,
    "emailAddress": newUserData.emailAddress,
    "hostingCredits": 0,
    "eventsOrganized": [],
    "eventsAttended": [],
    "city": "",
    "stateProvince": "",
    "country": "",
    "phoneCountry": "",
    "phoneCountryCode": "",
    "phoneNumberWithoutCountryCode": "",
    "instagram": "",
    "facebook": "",
    "x": "",
    "profileImage": newUserData.profileImage,
    "about": "",
    "subscriptionType": "",
    "interests": [],
    "friends": [],
    "friendRequestsReceived": [],
    "friendRequestsSent": [],
    "profileVisibleTo": newUserData.profileVisibleTo,
    "whoCanAddUserAsOrganizer": newUserData.whoCanAddUserAsOrganizer,
    "whoCanInviteUser": newUserData.whoCanInviteUser,
  });

  return fetch("http://localhost:4000/palz/users/", {
    method: "POST",
    headers: myHeaders,
    body: raw,
    redirect: "follow",
  });
};

// createUser() called inside handler handleNewAccountCreation():
/* As you can see, request success/failure is handled here (error is 
   logged to the console, and user is notified by a React Toast 
   popup). */
/* userData is the object that matches the required 'newUserData'
   parameter of createUser() */
const handleNewAccountCreation = (userData: TUser): void => {
    Requests.createUser(userData)
      .then((response) => {
        if (!response.ok) {
          setUserCreatedAccount(false);
        } else {
          setUserCreatedAccount(true);
        }
      })
      .catch((error) => {
        toast.error("Could not create account. Please try again.");
        console.log(error);
      });
  };
Enter fullscreen mode Exit fullscreen mode

Update user information

// Request function:
/* user parameter represents the user whose object must be updated. 
   The user's id is used to find that object (document) in the database. */
/* valuesToUpdate represents the object containing the property values
   on the user object that need to be updated. This is converted to JSON
   and set as the body of the PATCH request. */
const patchUpdatedUserInfo = (
  user: TUser | undefined,
  valuesToUpdate: TUserValuesToUpdate
): Promise<Response> => {
  const myHeaders = new Headers();
  myHeaders.append("Content-Type", "application/json");

  const raw = JSON.stringify(valuesToUpdate);

  return fetch(`http://localhost:4000/palz/users/${user?._id}`, {
    method: "PATCH",
    headers: myHeaders,
    body: raw,
    redirect: "follow",
  });
};

// Use patchUpdatedUserInfo() in a handler:
const handleUpdateProfileInfo = () => {
  // many other things...
  Requests.patchUpdatedUserInfo(currentUser, valuesToUpdate)
  .then((response) => {
    if (!response.ok) {
      toast.error("Could not update profile info. Please try again.");
      // other things...
    } else {
      toast.success("Profile info updated");
      // many other things...
    })
   .catch((error) => console.log(error));
   // other things...
}
Enter fullscreen mode Exit fullscreen mode

In handleUpdateProfileInfo above, there are several state values that will need to be changed, based on if the PATCH request succeeds or fails. Those state values are not accessible inside of requests.tsx, where the request is defined, so this is a good example of why you should set up error handling functions in request handlers defined in a component or context in your React app, not in the request function itself. To reiterate how to do this, take the code given to you by Postman, but return the fetch part (a Promise), delete the .then(), .catch() chain, then add that chain onto the call of the request function when you call it inside its handler.

Delete user

// Request function:
const deleteUser = (userID: string | undefined): Promise<Response> => {
  const myHeaders = new Headers();
  myHeaders.append("Content-type", "application/json");

  return fetch(`http://localhost:4000/palz/users/${userID}`, {
    method: "DELETE",
    headers: myHeaders,
    redirect: "follow",
  });
};

// Request function used in a handler:
const handleAccountDeletion = () => {
  // LOOOOTS of other things...
  Requests.deleteUser(currentUser?._id)
  .then((response) => {
    if (!response.ok) {
      toast.error("Account deletion incomplete; please try again.");
      fetchAllEvents();
      fetchAllUsers();
    } else {
      toast.error("You have deleted your account. We're sorry to see you go!");
      logout();
      navigation("/");
      fetchAllEvents();
    }
  })
  .catch((error) => console.log(error));
  // Other things...
 };
Enter fullscreen mode Exit fullscreen mode

So that was a list of a handler function for each type of HTTP CRUD request (except for PUT) related to users/; you may see all my request functions for the project here, then see how they’re used in handlers by searching ‘Requests.’, assuming you have it opened in an IDE.

At this point, your backend folder at the root of your project should look something like this ( userControllers.js, userModel.js, users.cjs, server.cjs, package.json, package-lock.json, node_modules/, & .env will be the only things there if you followed my instructions exactly, but this image shows you where you should add files relating to other endpoints, like /events & /messages).

Image description


I hope this article helps you unlock new powers as a developer. I used a patchwork of information from YouTube videos, documentation, & Stack Overflow posts when trying to figure this stuff out, so I felt the need to write down, step by step, how I connected a database to my frontend app, not only so I could better understand what I did, but to maybe help others who are looking to take their first step into the backend world.

This has been my longest article ever, so I thank & congratulate you if you made it to the end. I would love to hear your thoughts on it, so feel free to write a comment or give me a reaction. Pass it on to anyone who would find this helpful.

Keep building.


My project’s GitHub repository

https://www.mongodb.com/resources/products/fundamentals/create-database

MongoDB University

Top comments (0)