Build a CRUD app with Node.js

October 19, 2023
|
13
min read
Scott McAllister

On a recent trip to Europe, I made a new friend living in a different country than the one he grew up in. He loved his home country but also really enjoyed where he was at that moment. It was a place with a different culture and language, but he said that many people in Europe move countries often, and everyone finds a way to adapt and communicate. He told me it was necessary to find a common language to communicate with people from several countries moving into and passing through the area. He smiled and said, “It’s kind of like JavaScript.” 

JavaScript is everywhere and in all sorts of applications. There’s a good chance there is JavaScript in your current project, or at least inside some other projects at your company. With that thought in mind, I wanted to walk you through how to create a web application with a Node.js backend, save data in a MongoDB database, and build a web UI utilizing React.js. Then, I’ll show how easy it is to get that application online using ngrok. ngrok at its core is a unified ingress platform for developers. We make it easy to quickly and securely get your application online. As a part of this tutorial, I’ll show you how to test your app locally with the ngrok agent

As subject matter, I’ll drop the needle on my love for music and build an application that manages a music collection of vinyl records. I’ll show a list of our albums, provide a form for adding new ones, another form for editing existing records, and also show how to delete. All the code I’ll cover can be found in the ngrok-samples/javascript-crud-app repository on GitHub. You can clone that repo to follow along with this tutorial.

The application is separated into two major parts. The backend server is written in Node.js and utilizes the Express framework, which reads and writes information from the database. The server also contains a REST API that allows clients to interact with the same information. The RESTful nature of the server’s API decouples the frontend from the application's backend. This tutorial could just as easily have built a mobile app as a client or a website. In this case, I’ve built a web client to interact with our API. That client will be written in JavaScript and run on React.

Prerequisites

To follow along and run this project you’ll need to have the following software installed before you get started:

Build your Node.js REST API 

The primary purpose of your server will be to provide a database for saving and reading information about our vinyl records and a REST API for interacting with that database. You’ll use the Express framework to build the API endpoints. You’ll also grab the cors and body-parser middleware packages from the Express project to help us work better with the web frontend.  These packages will be installed later on when you run `npm install` command to install the project dependencies.

Add MongoDB

You will use MongoDB for storing data because its nature as a non-relational document database makes it highly compatible with storing JSON objects. MongoDB does have a driver for Node. However, you'll use the Mongoose library to streamline your interactions with the database. Mongoose is a library that acts as a wrapper for the MongoDB Node Driver and provides helper functions for common operations while working with the database. 

Start by ensuring MongoDB is installed and running, as the MongoDB service will need to be running for the vinyl record application to function properly.

The first interaction with the Mongoose library in server.js is by calling the connect function.

mongoose.connect('mongodb://127.0.0.1:27017/vinyl-records', { useNewUrlParser: true, useUnifiedTopology: true })
  .then(() => console.log('MongoDB Connected'))
  .catch(err => console.log(err));

This connects to the local instance of MongoDB on the default port (27017), using the vinyl-records database. If vinyl-records doesn’t exist then it’s created. The arguments for useNewUrlParser and useUnifiedTopology are set to suppress deprecation warnings sent by the MongoDB driver to the Mongoose library if you would prefer to see the deprecation messages feel free to remove those arguments.

Next, you’ll define the mongoose.Schema for the VinylRecord model. Keep only the record title and artist as required fields, leaving year, genre, and condition as optional.

const vinylRecordSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
  },
  artist: {
    type: String,
    required: true,
  },
  year: {
    type: Number,
  },
  genre: {
    type: String,
  },
  condition: {
    type: String,
  },
});

Next, define the mongoose.model using the vinylRecordSchema.

const VinylRecord = mongoose.model('VinylRecord', vinylRecordSchema);

Add API routes with Express

Now look at the routes for the REST API endpoints built with Express. To use the Express framework, initialize and configure the Express application object to use middleware functions for CORS and a JSON body parser.

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

The API routes come next. The Express app object provides methods corresponding to the incoming requests' related HTTP methods. For instance, you would send a POST HTTP request to the API to add a new vinyl record to our database. That request is handled by the app.post function that routes the request for the given path of /api/vinyl-records to the handler function we define.

app.post('/api/vinyl-records', (req, res) => {
  const { title, artist, year, genre, condition } = req.body;
  
  const newVinylRecord = new VinylRecord({
    title,
    artist,
    year,
    genre,
    condition,
  });
  
  newVinylRecord.save()
    .then(() => {
      res.status(201).json({ message: 'Vinyl record created successfully' });
    })
    .catch((error) => {
      res.status(400).json({ error: 'Unable to create vinyl record', details: error });
    });
  });

In this case, the handler function extracts the various field values from the HTTP request body to define a new VinylRecord mongoose object. The object is then saved to the database using the newVinylRecord.save() function. 

The following endpoint handles GET requests at /api/vinyl-records and retrieves a list of saved vinyl records from the database with a call to VinylRecord.find().

app.get('/api/vinyl-records', (req, res) => {
  VinylRecord.find()
    .then((vinylRecords) => {
      res.status(200).json(vinylRecords);
    })
    .catch((error) => {
      res.status(500).json({ error: 'Server error', details: error });
    });
});

The application technically has two read functions. The endpoint I just covered returns a list of all the records in our collection. However, you also want to be able to grab information about a single record. This is done by passing the record ID as part of the API endpoint path. Then, use the VinylRecord.findById(req.params.id) function to query the database for the album.

app.get('/api/vinyl-records/:id', (req, res) => {
  VinylRecord.findById(req.params.id)
    .then((vinylRecord) => {
      if (!vinylRecord) {
        return res.status(404).json({ error: 'Vinyl record not found' });
      }
      res.status(200).json(vinylRecord);
    })
    .catch((error) => {
      res.status(500).json({ error: 'Server error', details: error });
    });
});

Updating a record uses the same path for getting a single record except now you pass in the updated vinylRecord object as the body of the request. The database update function uses the VinylRecord.findByIdAndUpdate function from Mongoose. 

app.put('/api/vinyl-records/:id', (req, res) => {
  VinylRecord.findByIdAndUpdate(req.params.id, req.body, { new: true })
    .then((vinylRecord) => {
      if (!vinylRecord) {
        return res.status(404).json({ error: 'Vinyl record not found' });
      }
      res.status(200).json({ message: 'Vinyl record updated successfully' });
    })
    .catch((error) => {
      res.status(500).json({ error: 'Server error', details: error });
    });
  });

The delete endpoint also looks very similar to the update function, except now you’re only passing the ID of the record so that it can be deleted from the database using the VinylRecord.findByIdAndRemove function.

app.delete('/api/vinyl-records/:id', (req, res) => {
  VinylRecord.findByIdAndRemove(req.params.id)
    .then((vinylRecord) => {
      if (!vinylRecord) {
        return res.status(404).json({ error: 'Vinyl record not found' });
      }
      res.status(204).json({ message: 'Vinyl record deleted successfully' });
    })
    .catch((error) => {
      res.status(500).json({ error: 'Server error', details: error });
    });
  });

And, that’s all of our API endpoints. All you need to do now is start the Express server on the port we specified earlier in the code.

app.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

To run the application you’ll go into the project directory. Run npm install and then npm run start

Now let’s look at the frontend code.

Build your frontend

Because the Node backend is a REST API and completely decoupled from the frontend, the frontend code for our project lives in the vinyl-record-frontend directory. It was created by running the npx create-react-app vinyl-record-frontend command. I mention that because the directory contains a lot of the default React project boilerplate that we won’t focus on for this post. We’ll just look at the pieces that are unique and interesting to the project. 

Start with App.js

Digging inside the src folder, you’ll see App.js, which is the central hub for any React application. Here you’ll notice the react-router-dom package from Remix is being imported to handle client-side routing. Each Route defined represents a different view in the application UI. The route maps a path with an element. In this case, the elements are all custom React components. To use those components import them just below the import statement for react-router-dom.

import './App.css';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import VinylList from './components/VinylList';
import VinylAdd from './components/VinylAdd';
import VinylEdit from './components/VinylEdit';

function App() {
  return (
    <Router>
      <div>
        <h1>Spin the Black Circle!</h1>
        <div>Managing Your Vinyl Record Collection</div>
        <Routes>
          <Route path="/" element={<VinylList />} />
          <Route path="/add" element={<VinylAdd />} />
          <Route path="/edit/:vinylRecordId" element={<VinylEdit />} />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

The default path / routes to the VinylList component which displays a list of the record collection. Then /add takes the user to the VinylAdd form for adding an album to the database. And, finally, /edit/:vinylRecordId routes to a form for editing an existing album. Notice how the Routes are cleanly defined and easily represent the relationship between the path and the element. This keeps your code organized and straightforward to understand.

The code for the components is found in the aptly named components directory. Let’s take a look at each one.

Add the VinylList Component

The first Route displays the VinylList component. It uses the Effect Hook, seen in the code as useEffect to automatically call the vinyl records API and fetch all the vinyl records.  

useEffect(() => {
  fetchVinylRecords();
}, []);

The fetchVinylRecords function calls the vinyl-records API and then stores the response in the vinylRecords variable initialized at the top of the component to be used as part of the React State. Variables using State are defined with a name, and a setter function, and are initialized to useState(‘’). These variables can then be accessed throughout the VinylList component. Think of them as component-scoped variables.

const [vinylRecords, setVinylRecords] = useState([]);
const fetchVinylRecords = () => {
  axios.get('/api/vinyl-records')
    .then((response) => {
      setVinylRecords(response.data);
    })
    .catch((error) => {
      console.error(error);
    });
};

The other functionality in the VinylList component is the delete function. This is handled by making a DELETE request to the API and then refreshing the vinylRecord list by calling fetchVinylRecords afterward.

const deleteVinylRecord = (title, id) => {
  axios.delete(`/api/vinyl-records/${id}`)
    .then(() => {
      // refresh vinylRecord list
      fetchVinylRecords();
    })
    .catch((error) => {
      console.error(error);
    });
}

Add the VinylAdd Component

The VinylAdd component contains a form for adding new vinyl records to the database. The code repeats itself for each field, so I only want to highlight some interesting and unique aspects. First, you’re using React State to store the variables' values, making them accessible throughout the component.

const [title, setTitle] = useState('');

Then you will initialize a navigate variable that calls useNavigate from the React Router package. 

const navigate = useNavigate();

The navigate object will provide functions for setting the location by calling one of the defined paths in our Routes. This will be useful when processing form submissions and you want to take the user back to the default list view.

All but one of the form fields are simple text inputs that capture strings. Rather than looking at all of them, I’ll just show the Title input.

setTitle(e.target.value)}

The two interesting fields on this tag are value and onChange. onChange takes whatever is entered into the text field and passes it to the setTitle function, which sets the value of the title variable. This may sound like a lot of work happening during each keystroke, but the only time the value in the text field matters is when you hit the Submit button.

The other unique field in the form is the Condition dropdown. The option value for the select tag uses the values of the contitionOptions array created earlier. 

<label>
  Condition:
  <select value={condition} onChange={(e) => setCondition(e.target.value)}>
    {conditionOptions.map((option) => (
      <option key={option} value={option}>
        {option}
      </option>
    ))}
  </select>
</label>

And, finally, there are the button handlers. These functions process the actions triggered by clicking the Submit and Cancel buttons. 

handleFormSubmit takes the values entered into the form fields–and saved in the React State– and passes them in a POST request to the /api/vinyl-records endpoint. Assuming the request receives a successful response, navigate(‘/’) sends the user back to the default list of records. The handleCancel function skips making any calls to the API and just navigates back to home.

const handleFormSubmit = (e) => {
  e.preventDefault();

  axios.post('/api/vinyl-records', { title, artist, year, genre, condition })
    .then(() => {
      // navigate route to default List view
      navigate('/');
    })
    .catch((error) => {
      console.error(error);
    });
};

const handleCancel = () => {
  // navigate route to default List view
  navigate('/');
};

Add the VinylEdit Component

The VinylEdit component is strikingly similar to VinylAdd. In fact, if you wanted to take this project to the next level, you could probably figure out how to make the form a shared component that VinylAdd and VinylEdit referenced. Alas, for the sake of simplicity, I’ll keep the two form components separated for this conversation.

The two differences between the VinylEdit component and VinylAdd are the querying for and displaying of the data for the saved vinyl record in the form when the page loads. Then, when you submit the form it requests a PUT to the API to update that information.

To trigger the GET request on the API as the page loads call the useEffect function that wraps the API call, sending in the vinylRecordId value grabbed from the path of the edit page.

useEffect(() => {
  axios.get(`/api/vinyl-records/${vinylRecordId}`) // Adjust the route to match your API
    .then((response) => {
      const record = response.data;
      setTitle(record.title);
      setArtist(record.artist);
      setYear(record.year);
      setGenre(record.genre);
      setCondition(record.condition);
    })
    .catch((error) => {
      console.error(error);
    });
  }, [vinylRecordId]);

Similarly, when the form is submitted, the handleFormSubmit function takes the form values from the React State and passes them to the axios.put request.

const handleFormSubmit = (e) => {
  e.preventDefault();

  axios.put(`/api/vinyl-records/${vinylRecordId}`, { title, artist, year, genre, condition })
  .then(() => {
    // navigate route to list
    navigate('/');
  })
  .catch((error) => {
    console.error(error);
  });
};

Run the React App

To fire up the vinyl-record-frontend project first run npm install inside the directory to get the needed dependencies and then npm run start

However, when you run those commands you may notice the webpage pull up without any visible problems. That is, until you check out the console and see a series of 404 (Not Found) errors that look like this:

The client-side code–running on port 3000–assumes the server-side API is running on the same port when the server is actually running on port 3500. You can fix this issue by setting up a proxy value in the package.json of the vinyl-record-frontend project, adding the following line.

"proxy": "http://localhost:3500",

Restart the project by killing the process and running npm run start again on the frontend project. Now you should have a fully functional application running on http://localhost:3000/.

Add ngrok for testing with React

Running on localhost is awesome if you work alone and don’t want anyone to see or use your site. But what if you want to get your application online and show the world? That’s where ngrok comes in. Using the ngrok agent you can get the frontend application online with the following command: 

ngrok http 3000 --host-header="localhost:3000"

The command tells ngrok to begin a session where HTTP traffic should be forwarded to port 3000 on my local machine. The  –host-header argument is needed to rewrite the host header on the requests passing through because React expects the requests to be coming from port 3000 on localhost.

The ngrok session generates a random ngrok subdomain that can be used to access your application running on port 3000 of your machine. Because this URL is randomly generated by ngrok, yours will differ from the one you see below. You can also use a static domain or a custom domain you already own, but configuring those is outside the scope of this post. 

Kicking off an ngrok session will provide output similar to what you see above, with the forwarding URL and other connection information. During the session, the ngrok agent routes traffic through the ngrok infrastructure which will take care of all the necessary DNS entries, TLS certificates, load balancing, DDoS protection, and everything else needed to create the connection.

Go ahead and take the generated URL for a… SPIN! You should be able to use the vinyl record application to create, read, update, and delete records from your list. You can even share the link with your friends to show them your cool new application that you built using Node, Express, React, React Routers, and MongoDB. Be proud of yourself, that was a lot to learn! 

The next step is to deploy our application more permanently. You see, using the ngrok agent is excellent during development and testing. However, when you end the ngrok session your users will lose connection to your application. In a future post, we’ll show you how ngrok can just as easily provide secure ingress to your application in production. 

Learn more

Interested in learning more about other development topics like testing authentication, ngrok, or how to get started with ingress to your production apps? We’ve got you covered with some other awesome content from the ngrok blog:

Questions or comments? Hit us up on X (aka Twitter) @ngrokhq or LinkedIn, or join our community on Slack.

Share this post
Scott McAllister
Scott McAllister is a Developer Advocate for ngrok, helping others learn about a wide range of web technologies and incident management principles.
JavaScript
Other
None