Build a CRUD app with Node.js
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:
- Kubernetes ingress with ngrok and Hashicorp Consul
- Add authentication to your ngrok traffic with Auth0
- Build and test a CRUD app in Go
Questions or comments? Hit us up on X (aka Twitter) @ngrokhq or LinkedIn, or join our community on Slack.