alisdairbr
Posted on June 17, 2022
Introduction
MERN is a full-stack solution named after the technologies that make up the stack: MongoDB, Express, React, and Node.js.
- M - MongoDB is a NoSQL document-based database. Databases are used to persist any data the users will need. In this guide, we are going to use MongoDB Atlas, MongoDB's managed database solution.
- E - Express.js is a flexible and minimalist web framework for building Node.js applications
- R - React.js is a front-end frameowrk that lets you build interactive UIs.
- N - Node.js is an asynchronous event-driven JavaScript runtime designed to build scalable network applications.
Here is a schema for an overview of how these technologies interact to form a web application.
React is used to create the components on the client-side of the application while Express and Node.js are used for building the server-side. Then, MongoDB is used to persist data for the application.
This is the first guide in a mini-series focused on the popular MERN stack. In this guide, we will create a sample blog app.
The second guide in this mini-series will focus on creating a microservice to add extra search capabilities to this blog app by using Mongo Atlas Search.
At the end of this guide we will have a full-functioning basic blog web app where authors can post, edit and delete articles. To complete the tutorial, the application will be deployed on the internet by using the Koyeb serverless platform.
We will deploy our application to Koyeb using git-driven deployment, which means all changes we make to our application's repository will automatically trigger a new build and deployment on the serverless platform. By deploying on Koyeb, our application will benefit from native global load balancing, autoscaling, autohealing, and auto HTTPS (SSL) encryption with zero configuration on our part.
Requirements
To successfully follow this tutorial, you need the following:
- A local environment with Yarn and Node.js installed
- A MongoDB Atlas account to create a managed MongoDB database
- A Postman account and Postman Desktop Agent to test the API
- A GitHub account to version and deploy your application code on Koyeb
- A Koyeb account to deploy and run the application
Steps
The steps to creating a blog application with a MERN stack and deploying it to production on Koyeb include:
- Set up the blog application project
- Create a MongoDB Atlas database
- Define the blog post model and the article schema
- Implement the schema using Mongoose
- Configure the blog's API endpoints with Express
- Test the API endpoints using Postman
- Set up the blog's UI with React, Axios, and reusable components
- Deploy the blog app on Koyeb
Set up the blog application project
To get started, create the project folder mongo-blog
and install all the related dependencies. Open your terminal and create the project folder:
mkdir mongo-blog
Move into mongo-blog
and setup Express using express-generator
:
cd mongo-blog
npx express-generator
By using npx we can execute express-generator without installing the package.
You will be prompted several questions to create the package.json
file such as the project's name, version, and more.
Add the following code to the package.json
file:
{
"name": "mongo-blog",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www"
},
"dependencies": {
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"morgan": "~1.9.1"
}
}
Next, we are going to add 2 more packages:
-
nodemon
to reload the server. As we are developing in our local environment, we want our server to reload whenever a change in the code occurs. -
cors
to allow cross-origin resource sharing. This is important when the React-based client calls the server API in our local environment.
In your terminal, install them by running:
yarn add nodemon --save-dev
yarn add cors
The option "--save-dev" installed nodemon as a devDependency, which are packages that are only needed for local development. Perfect for us since we only need it for local development.
Open your package.json
and add one more command under scripts
:
{
...
"scripts": {
+ "dev": "nodemon ./bin/www",
"start": "node ./bin/www"
},
...
In app.js
we are going to require cors
and attach it to the app:
const createError = require('http-errors');
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
const logger = require('morgan');
const cors = require('cors');
const indexRouter = require('./routes/index');
const usersRouter = require('./routes/users');
const app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(cors());
app.use('/', indexRouter);
app.use('/users', usersRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
We are going to use mongoose
, a very straight-forward ORM built for Node, to model our application data and connect to a Mongo database to store our posts. Add it by running:
yarn add mongoose
Next, we need to add an extra script to build the client bundle.js
. In package.json
, add the extra script so your file looks like this:
{
...
"scripts": {
"dev": "nodemon ./bin/www",
"start": "node ./bin/www",
+ "build-client": "cd ./client && yarn build"
},
...
Next, run yarn install
in the terminal to install the packages.
Now, we can move on to setting up the client. First, at the root of your project directory create a folder /client
, move into this folder and install React using create-react-app
:
mkdir client
cd client
npx create-react-app .
Similarly to express-generator
, this command will create a ready-to-go React project hiding most of the tedious configurations required in the past.
On top of the basic packages, like react
and react-dom
, we have to think about what other packages our blog client needs:
- The client will make API calls to the server to perform basic CRUD operations on the database.
- There are gonna be different pages to create, read, edit and delete blog posts.
- We want there to be forms to create and edit a post.
These are very common functionalities and fortunately the yarn ecosystem offers tons of different packages. For the purpose of the tutorial, we are gonna install axios
to make API calls, react-router-dom
to handle client routing and react-hook-form
to submit form data.
In the terminal, go ahead and install them under /client
:
yarn add axios react-router-dom react-hook-form
For our application, the server and client share the same repository. This means we can use the folder /public
located in the project's root directory to return the static client after it is built. To do this, we need to tweak the "build" script inside /client/package.json
to build the static files in it:
{
...
"scripts": {
"start": "react-scripts start",
+ "build": "BUILD_PATH='../public' react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
...
Under /client/src
, edit the index.js
file:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
);
reportWebVitals();
This creates easy entry points for the components we are going to build for our blog.
Now, let's talk about styling. We don't really want to spend too much time dealing with CSS so we are using Bootstrap, specifically react-bootstrap
so that we can include all the UI components we need without really adding CSS. From /client
, run:
yarn add bootstrap@5.1.3 react-bootstrap
Finally, we are going to drop one file to prepare for our deployment: package-lock.json
. From your project's root directory:
rm package-lock.json
If you want to verify that you setup everything correctly, take a look at project directory structure:
├── app.js
├── bin
│ └── www
├── package.json
├── public
│ ├── images
│ ├── javascripts
│ └── stylesheets
│ └── style.css
├── routes
│ ├── index.js
│ └── users.js
└── views
├── error.pug
├── index.pug
└── layout.pug
└── client
├── package.json
├── yarn.lock
├── public
└── src
├── App.js
├── App.css
├── App.test.js
├── index.js
├── index.css
├── logo.svg
├── reportWebVitals.js
└── setupTests.js
Go ahead and start the server by running yarn dev
on the terminal, then open the browser at http://localhost:3000
and if everything was setup correctly you should see a welcome message from Express.
Create a database on Mongo Atlas
The easiest way to create our MongoDB database is to use MongoDB Atlas. MongoDB Atlas hosts databases on AWS, Google Cloud, Azure and makes it easy to operate and scale your Mongo database.
From the "Database Deployments" page, click "Build a Database".
- Choose the "shared" plan which starts for free.
- Select your preferred cloud provider and region.
- Enter a cluster name, liike "mongo-blog-db".
- Click the "Create Cluster" button.
- Select the "Username & Password" authentication option, enter a username and password and click the "Create User button". Store the username and password somewhere safe, we will use this information during deployment.
- Enter "0.0.0.0/0" without the quotes into the IP Address field of the IP Access List section, and click the "Add Entry" button.
- Click the "Finish and Close" button and then the "Go to Databases" button. You will be redirected to the "Data Deployments" page, with your new MongoDB cluster now visible.
- Click the "Connect" button next to your MongoDB cluster name, select the "Connect your application" option and copy your database connection string to a safe place for later use. A typical connection string should look like this:
mongodb+srv://<username>:<password>@mongo-client-db.r5bv5.mongodb.net/<database_name>?retryWrites=true&w=majority
You have now created a MongoDB database!
To connect the database to our application, move back the codebase. Open app.js
and add this code to require mongoose
, connect it to the database by using the connection string, and recover from potential errors:
...
const mongoose = require('mongoose');
const CONNECTION_STRING = process.env.CONNECTION_STRING;
// setup connection to mongo
mongoose.connect(CONNECTION_STRING);
const db = mongoose.connection;
// recover from errors
db.on('error', console.error.bind(console, 'connection error:'));
...
Since the connection string is an environment variable, to test it in development we can add it to the package.json
:
{
...
"devDependencies": {
"nodemon": "^2.0.15"
},
+ "nodemonConfig": {
+ "env": {
+ "CONNECTION_STRING": "YOUR_CONNECTION_STRING"
+ }
+ }
}
To ensure everything is running as expected, run the application locally:
yarn dev
Define the blog post model and the article schema
With the database now up and running, it is time to create our first model Post
.
The basic schema for a blog post is defined by a title, the content of the post, the author, a creation date and optionally tags. The following should help us visualize the schema:
Fields | Type | Required |
---|---|---|
title | String | X |
author | String | X |
content | String | X |
tags | Array | |
createdAt | Date | X |
Implement the schema using Mongoose
Mongoose's straightforward syntax makes creating models a very simple operation. At the root of your project, add a new folder models
and add a post.js
file there:
mkdir models
touch /models/post.js
Add this code to the post.js
file:
// Dependencies
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
// Defines the Post schema
const PostSchema = new Schema({
title: { type: String, required: true },
content: { type: String, required: true },
author: { type: String, required: true },
tags: { type: [String] },
createdAt: { type: Date, default: Date.now },
});
// Sets the createdAt parameter equal to the current time
PostSchema.pre('save', (next) => {
now = new Date();
if (!this.createdAt) {
this.createdAt = now;
}
next();
});
// Exports the PostSchema for use elsewhere.
module.exports = mongoose.model('Post', PostSchema);
Here is an explanation of what we are doing here:
- Require Mongoose and use the
Schema
class to createPostSchema
. - When creating the object
PostSchema
, we add the fields title, content, author, tags, createdAt. - Instruct
PostSchema
to automatically add the creation date right before saving the new post inside the database for us. - We export the model to use it within our controllers to perform CRUD operations on the posts.
Configure the blog's API endpoints with Express
Now that we have completed the modelling of our blog posts we can create API endpoints to work with them. As mentioned earlier, our blog app allows users to write, read, edit and delete posts. Now we will code a few endpoints to achieve all that. Specifically:
- GET
/api/posts
returns all the posts in descending order, from the latest to the earliest. - GET
/api/posts/:id
returns a single blog post given its id. - POST
/api/posts
saves a new blog post into the db. - PUT
/api/posts/:id
updates a blog post given its id. - DELETE
/api/posts/:id
deletes a blog post.
Create CRUD endpoints using express routes
Thanks to express-generator
scaffolding we already have the routes folder /routes
inside mongo-blog
. Inside routes
, create a new file posts.js
:
touch /routes/posts.js
Using the express Router
object we are going to create each endpoint. The first one, GET /api/posts
retrieves the posts using our newly created Post model function find()
, sorts them using sort()
and then returns the whole list to the client:
const express = require('express');
const router = express.Router();
// Require the post model
const Post = require('../models/post');
/* GET posts */
router.get('/', async (req, res, next) => {
// sort from the latest to the earliest
const posts = await Post.find().sort({ createdAt: 'desc' });
return res.status(200).json({
statusCode: 200,
message: 'Fetched all posts',
data: { posts },
});
});
...
In one single line of code we fetched and sorted the post, that's Mongoose magic!
We can implement GET /api/posts/:id
similarly but this time we are using findById
and we are passing the URL parameter id
. Add the following to posts.js
:
...
/* GET post */
router.get('/:id', async (req, res, next) => {
// req.params contains the route parameters and the id is one of them
const post = await Post.findById(req.params.id);
return res.status(200).json({
statusCode: 200,
message: 'Fetched post',
data: {
post: post || {},
},
});
});
...
If we cannot find any post with the id
that is passed, we still return a positive 200 HTTP status with an empty object as post.
At this point, we have functioning endpoints but without any posts in the database, so we cannot really do much. To change this, we will create a POST /api/posts
endpoint, so we can start adding posts.
In req.body
we will collect the title, author, content and tags coming from the client, then create a new post, and save it into the database. Add the following to posts.js
:
...
/* POST post */
router.post('/', async (req, res, next) => {
const { title, author, content, tags } = req.body;
// Create a new post
const post = new Post({
title,
author,
content,
tags,
});
// Save the post into the DB
await post.save();
return res.status(201).json({
statusCode: 201,
message: 'Created post',
data: { post },
});
});
...
Next, we want to retrieve and update a post. For this action, we can create a PUT /api/posts/:id
endpoint while Mongoose provides a handy function findByIdAndUpdate
. Again, add this code to posts.js
:
...
/* PUT post */
router.put('/:id', async (req, res, next) => {
const { title, author, content, tags } = req.body;
// findByIdAndUpdate accepts the post id as the first parameter and the new values as the second parameter
const post = await Post.findByIdAndUpdate(
req.params.id,
{ title, author, content, tags },
);
return res.status(200).json({
statusCode: 200,
message: 'Updated post',
data: { post },
});
});
...
The last action we will add is the ability to delete a specific blog post by sending its id
. Mongoose once again provides a function deleteOne
that we can use to tell our Mongo database to delete the post with that id
. Add the following to posts.js
:
...
/* DELETE post */
router.delete('/:id', async (req, res, next) => {
// Mongo stores the id as `_id` by default
const result = await Post.deleteOne({ _id: req.params.id });
return res.status(200).json({
statusCode: 200,
message: `Deleted ${result.deletedCount} post(s)`,
data: {},
});
});
module.exports = router;
Following the steps above, we have just built our new router. Now, we have to attach it to our server and test it out using Postman, an API platform for building and using APIs. Open app.js
and under indexRouter
go ahead and add postsRouter
as well. At this point, your app.js
file should look like this:
const createError = require('http-errors');
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
const logger = require('morgan');
const mongoose = require('mongoose');
const cors = require('cors');
const CONNECTION_STRING = process.env.CONNECTION_STRING;
const indexRouter = require('./routes/index');
const postsRouter = require('./routes/posts');
const app = express();
// view engine setup to a
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');
// setup connection to mongo
mongoose.connect(CONNECTION_STRING);
const db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(cors());
app.use('/', indexRouter);
app.use('/api/posts', postsRouter);
// Return the client
app.get('/posts*', (_, res) => {
res.sendFile(path.join(__dirname, 'public') + '/index.html');
});
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
Test the API endpoints using Postman
In the absence of a client, we can use POSTMAN to test our API. Extremely flexible and easy to use, Postman allows us to specificy the type of request (i.e., GET, POST, PUT, and DELETE); the type of payload, if any; and several other options to fine-tune our tests.
If you closed the server, go ahead and start it again in the terminal by running yarn dev
.
We currently have an empty database, so the very first test can be the creation of a post. To create a post, specify that we want a POST request to http://localhost:3000/api/posts
. For the body payload, select raw
and choose JSON
in the dropdown menu, so that we can use JSON syntax to create it. Here is the result of the call:
To make sure the post was really created, we can make a call to http://localhost:3000/api/posts
to get the full list of posts as well as http://localhost:3000/api/posts/:post_id
to fetch the single post:
Since we have just one post, the result of the API calls should be almost the same as GET /api/posts
returns an array of posts with a single item in it.
If you want to update the post, for example if you want to change the title and add an extra tag, you can pass the new data in the API call JSON body:
If you are unsure whether it was correctly updated, go ahead and call GET /api/posts/post_id
again:
Finally, test that deleting the post works as expected:
Run GET /api/posts
again and you should get an empty list of posts as result:
Set up the blog's UI with React, Axios, and reusable components
Since the server-side of the application is now complete, it is now time work on the client-side of the application.
Client routes and basic layout
One of the very first things to define are the routes of our web application:
- The home page
- Single blog posts pages
- Create a new post and edit posts
With that in mind, here are the proposed URLs:
URL | Description |
---|---|
/ | Home page |
/posts/:post_id | Post content page |
/posts/new | Page to create a new post |
/posts/:post_id/edit | Page to edit a post |
The routes will all reside under /client/src/App.js
using react-router-dom
components Routes
and Route
. Move into App.js and edit the file with the following:
import { Routes, Route } from 'react-router-dom';
import Home from './pages/home';
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
</Routes>
);
}
export default App;
In this example we are rendering the Home
component when the browser hits the home page.
App.js
acts as the root component of our client, so we can imagine the shared layout of our blog being rendered through App
. Our blog page will have a Navbar with a button that will let you create a new post. This Navbar will be visible on every page of our client application, so it is best to render it here in App.js
. Move into App.js
and add this code:
// Import Bootstrap CSS
import 'bootstrap/dist/css/bootstrap.min.css';
import { Routes, Route } from 'react-router-dom';
import Home from './pages/home';
// Import the Navbar, Nav and Container components from Bootstrap for a nice layout
import Navbar from 'react-bootstrap/Navbar';
import Nav from 'react-bootstrap/Nav';
import Container from 'react-bootstrap/Container';
function App() {
return (
<>
<Navbar bg="dark" expand="lg" variant="dark">
<Container>
<Navbar.Brand href="/">My Blog</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Nav className="me-auto">
<Nav.Link href="/posts/new">New</Nav.Link>
</Nav>
</Container>
</Navbar>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</>
);
}
export default App;
In a few lines of code we created a decent layout that. Once we implement Home
, our home page should look like this:
We previously defined all the client routes, so we can add them all in App
along with main components that we will implement later:
import 'bootstrap/dist/css/bootstrap.min.css';
import { Routes, Route } from 'react-router-dom';
// We are going to implement each one of these "pages" in the last section
import Home from './pages/home';
import Post from './pages/post';
import Create from './pages/create';
import Edit from './pages/edit';
import Navbar from 'react-bootstrap/Navbar';
import Nav from 'react-bootstrap/Nav';
import Container from 'react-bootstrap/Container';
function App() {
return (
<>
<Navbar bg="dark" expand="lg" variant="dark">
<Container>
<Navbar.Brand href="/">My Blog</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Nav className="me-auto">
<Nav.Link href="/posts/new">New</Nav.Link>
</Nav>
</Container>
</Navbar>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/posts/:id" element={<Post />} />
<Route path="/posts/new" element={<Create />} />
<Route path="/posts/:id/edit" element={<Edit />} />
</Routes>
</>
);
}
export default App;
Axios client
Our client will have to make API calls to the server to perform operations on the database. This is why we installed axios
earlier.
We will wrap it inside an http
library file and export it as a module. We do this for two reasons:
- We need to take into account that making API calls in local is like calling a different server. As client and servers run on different ports, this is a completely different configuration compared to the deployment we will do on Koyeb later on.
- The HTTP object is exported along with the basic methods to call GET, POST, PUT and DELETE endpoints.
In /client/src
, create a new folder /lib
and inside add an http.js
file:
mkdir lib
touch /lib/http.js
Add the following code to http.js
:
import axios from 'axios';
// When building the client into a static file, we do not need to include the server path as it is returned by it
const domain = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3000';
const http = (
url,
{
method = 'GET',
data = undefined,
},
) => {
return axios({
url: `${domain}${url}`,
method,
data,
});
};
// Main functions to handle different types of endpoints
const get = (url, opts = {}) => http(url, { ...opts });
const post = (url, opts = {}) => http(url, { method: 'POST', ...opts });
const put = (url, opts = {}) => http(url, { method: 'PUT', ...opts });
const deleteData = (url, opts = {}) => http(url, { method: 'DELETE', ...opts });
const methods = {
get,
post,
put,
delete: deleteData,
};
export default methods;
We have just finished setting up our client to make API calls to the server to perform operations on the database.
In the next section, we will see how we can use the http
object.
Create containers and reusable components
React is component-based, meaning that we can create small and encapsulated components and reuse them all over the web application as basic building pieces for more complex UIs.
The very first component we are going to build is Home
, which in charge of rendering the list of posts as well as the header of the home page.
To render the list of posts, Home
has to:
- Call the server GET
/api/posts
endpoint after the first rendering - Store the array posts in the state
- Render the posts to the user and link them to
/posts/:post_id
to read the content
Under /client/src
, create a folder /pages
and a file home.js
in it:
mkdir pages
touch pages/home.js
Add the following code to home.js
:
import { useEffect, useState } from 'react';
// Link component allow users to navigate to the blog post component page
import { Link } from 'react-router-dom';
import Container from 'react-bootstrap/Container';
import ListGroup from 'react-bootstrap/ListGroup';
import Image from 'react-bootstrap/Image';
import http from '../lib/http';
// utility function to format the creation date
import formatDate from '../lib/formatDate';
const Home = () => {
// useState allows us to make use of the component state to store the posts
const [posts, setPosts] = useState([]);
useEffect(() => {
// Call the server to fetch the posts and store them into the state
async function fetchData() {
const { data } = await http.get('/api/posts');
setPosts(data.data.posts);
}
fetchData();
}, []);
return (
<>
<Container className="my-5" style={{ maxWidth: '800px' }}>
<Image
src="avatar.jpeg"
width="150"
style={{ borderRadius: '50%' }}
className="d-block mx-auto img-fluid"
/>
<h2 className="text-center">Welcome to the Digital Marketing blog</h2>
</Container>
<Container style={{ maxWidth: '800px' }}>
<ListGroup variant="flush" as="ol">
{
posts.map((post) => {
// Map the posts to JSX
return (
<ListGroup.Item key={post._id}>
<div className="fw-bold h3">
<Link to={`/posts/${post._id}`} style={{ textDecoration: 'none' }}>{post.title}</Link>
</div>
<div>{post.author} - <span className="text-secondary">{formatDate(post.createdAt)}</span></div>
</ListGroup.Item>
);
})
}
</ListGroup>
</Container>
</>
);
};
export default Home;
About formatDate
, this is a utility function that formats the post creation date to "Month DD, YYYY". We are expecing to call it in other components as well. This is why it is decoupled from Home
into its own file.
In the terminal create the file formatDate.js
under /lib
:
touch lib/formatDate.js
Add the following to the formatDate.js
file:
const formatDate = (date, locale = 'en-US') => {
if (!date) return null;
const options = { year: 'numeric', month: 'long', day: 'numeric' };
const formattedDate = new Date(date);
return formattedDate.toLocaleDateString(locale, options);
};
export default formatDate;
The 'formatDate' function takes the date from the database, creates a Date
object and formats it by setting locale and options. The resulting UI will look like this:
Next, we will set up the part of the UI to display the blog posts. The logic behind showing the blog post content is not too different than the one we saw for Home
:
- When hitting
/posts/post_id
the client calls the server API to fetch the specific blog post. - The post is stored in the component state.
- Using react-boostrap, we create a simple-but-effective UI for the users to read the post.
- On top of this, we add 2 buttons to either "edit" or "delete" the posts. Specifically, "edit" is nothing more than a link to
/posts/post_id/edit
and delete calls DELETE/api/posts/:post_id
and then redirects the user to the home page.
Open the terminal and create a post.js
under /pages
:
touch post.js
Add the following code to post.js
:
import { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import Container from 'react-bootstrap/Container';
import Button from 'react-bootstrap/Button';
import http from '../lib/http';
import formatDate from '../lib/formatDate';
const Post = () => {
const { id: postId } = useParams();
const [post, setPost] = useState({});
const navigate = useNavigate();
// Fetch the single blog post
useEffect(() => {
async function fetchData() {
const { data } = await http.get(`/api/posts/${postId}`);
setPost(data.data.post);
}
fetchData();
}, [postId]);
// Delete the post and redirect the user to the homepage
const deletePost = async () => {
await http.delete(`/api/posts/${postId}`);
navigate('/');
}
return (
<>
<Container className="my-5 text-justified" style={{ maxWidth: '800px' }}>
<h1>{post.title}</h1>
<div className="text-secondary mb-4">{formatDate(post.createdAt)}</div>
{post.tags?.map((tag) => <span>{tag} </span>)}
<div className="h4 mt-5">{post.content}</div>
<div className="text-secondary mb-5">- {post.author}</div>
<div className="mb-5">
<Link
variant="primary"
className=" btn btn-primary m-2"
to={`/posts/${postId}/edit`}
>
Edit
</Link>
<Button variant="danger" onClick={deletePost}>Delete</Button>
</div>
<Link to="/" style={{ textDecoration: 'none' }}>← Back to Home</Link>
</Container>
</>
);
};
export default Post;
The UI will look like this:
As we will redirect the user to another page when editing the blog post, create the file edit.js
inside /pages
:
touch edit.js
The UI will show a form filled with the blog post data for title, author, content and tags. Users can
- Edit each one of the fields
- Submit the data to the server by calling PUT
/api/posts/:post_id
Note that we are using react-hook-form
to register fields, collect the data and submit to the server. In this tutorial, we are not performing any validation on the data but it is fairly simple to be added thanks to react-hook-form simple API.
Add the following code to edit.js
:
import { useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import Container from 'react-bootstrap/Container';
import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form';
import http from '../lib/http';
const Edit = () => {
const { id: postId } = useParams();
const navigate = useNavigate();
const { register, handleSubmit, reset } = useForm();
// we call the API to fetch the blog post current data
useEffect(() => {
async function fetchData() {
const { data } = await http.get(`/api/posts/${postId}`);
// by calling "reset", we fill the form fields with the data from the database
reset(data.data.post);
}
fetchData();
}, [postId, reset]);
const onSubmit = async ({ title, author, tags, content }) => {
const payload = {
title,
author,
tags: tags.split(',').map((tag) => tag.trim()),
content,
};
await http.put(`/api/posts/${postId}`, { data: payload });
navigate(`/posts/${postId}`);
};
return (
<Container className="my-5" style={{ maxWidth: '800px' }}>
<h1>Edit your Post</h1>
<Form onSubmit={handleSubmit(onSubmit)} className="my-5">
<Form.Group className="mb-3">
<Form.Label>Title</Form.Label>
<Form.Control type="text" placeholder="Enter title" {...register('title')} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Author</Form.Label>
<Form.Control type="text" placeholder="Enter author" {...register('author')} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Tags</Form.Label>
<Form.Control type="text" placeholder="Enter tags" {...register('tags')} />
<Form.Text className="text-muted">
Enter them separately them with ","
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Content</Form.Label>
<Form.Control as="textarea" rows={3} placeholder="Your content..." {...register('content')} />
</Form.Group>
<Button variant="primary" type="submit">Save</Button>
</Form>
<Link to="/" style={{ textDecoration: 'none' }}>← Back to Home</Link>
</Container>
);
};
export default Edit;
With a centralized app state, we would not need to call the API once again as we would have the post data already available in the client. However, in order not to avoid adding extra business logic to pass data on different views or handle refreshing the page, we simply call /api/posts/post_id
once again.
Here is the page UI as of now:
The final action we will add is to allow users the ability to create their own posts. We already created the button "New" in the navbar that redirects to /posts/new
.
Similarly to the previous page edit.js
, we prompt a form for the user to fill out. Fields are initially empty as we are expecting to store a brand new blog post in the database.
Add a new file create.js
in /pages
and enter the following code:
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import Container from 'react-bootstrap/Container';
import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form';
import http from '../lib/http';
const Post = () => {
const navigate = useNavigate();
const { register, handleSubmit } = useForm();
const onSubmit = async ({ title, author, tags, content }) => {
const payload = {
title,
author,
tags: tags.split(',').map((tag) => tag.trim()),
content,
};
await http.post('/api/posts', { data: payload });
navigate('/');
};
return (
<Container className="my-5" style={{ maxWidth: '800px' }}>
<h1>Create new Post</h1>
<Form onSubmit={handleSubmit(onSubmit)} className="my-5">
<Form.Group className="mb-3">
<Form.Label>Title</Form.Label>
<Form.Control type="text" placeholder="Enter title" {...register('title')} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Author</Form.Label>
<Form.Control type="text" placeholder="Enter author" {...register('author')} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Tags</Form.Label>
<Form.Control type="text" placeholder="Enter tags" {...register('tags')} />
<Form.Text className="text-muted">
Enter them separately them with ","
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Content</Form.Label>
<Form.Control as="textarea" rows={3} placeholder="Your content..." {...register('content')} />
</Form.Group>
<Button variant="primary" type="submit">Publish</Button>
</Form>
<Link to="/" style={{ textDecoration: 'none' }}>← Back to Home</Link>
</Container>
);
};
export default Post;
To start the create-react-app, run yarn start
in the terminal. By default it runs on port 3000, which is currently used by the Express server. So, in the terminal create-react-app is going to suggest using a different port, most likely 3001. Click "Enter" and the client app will restart on port 3001.
If you want to add an image to your homepage, add it under /client/public
as avatar.jpeg
. When you are done, your UI should resemble this:
Congratulations, we finished building the UI! We are now ready to deploy our blog app on the internet!
Deploy the blog app on Koyeb
We are going to deploy our application on Koyeb using git-driven deployment with GitHub. Each time a change is pushed to our application, this will automatically trigger Koyeb to perform a new build and deployment of our application. Once the deployment passes necessary health checks, the new version of our application is promoted to the internet.
In case the health checks are not passed, Koyeb will maintain the latest working deployment to ensure our application is always up and running.
Before we dive into the steps to deploy on the Koyeb, we need to remove the connection string to the Mongo database from our code as we will inject it from the deployment configuration for security.
Before we dive into the steps to deploy on the Koyeb, we need to remove the connection string to the Mongo database from our code as we will inject it from the deployment configuration for security. Update your package.json
file by removing the connection string we added earlier to test our application locally:
{
"name": "mongo-blog",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "nodemon ./bin/www",
"start": "node ./bin/www",
"build-client": "cd ./client && yarn build"
},
"dependencies": {
"cookie-parser": "~1.4.4",
"cors": "^2.8.5",
"debug": "~2.6.9",
"express": "~4.16.1",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"mongoose": "^6.2.3",
"morgan": "~1.9.1"
},
"devDependencies": {
"nodemon": "^2.0.15"
}
}
To deploy on Koyeb, we need to create a new GitHub repository from the GitHub web interface or using the GitHub CLI with the following command:
gh repo create <YOUR_GITHUB_REPOSITORY> --private
Initialize a new git repository on your machine and add a new remote pointing to your GitHub repository:
git init
git remote add origin git@github.com:<YOUR_GITHUB_USERNAME>/<YOUR_GITHUB_REPOSITORY>.git
git branch -M main
Add all the files in your project directory to the git repository and push them to GitHub:
git add .
git commit -m "Initial commit"
git push -u origin main
Once your code is added to your GitHub repository, log in on Koyeb and from the Control Panel, click on the button "Create App".
On the App Creation Page, fill in:
- Name your application, for example
mern-blog
. - For "Deployment method", choose Github.
- Select the git repository and specify the branch where you pushed the code to. In my case,
main
. - In application configuration, add the build command "yarn build-client" and the start command "yarn start"
- Add a Secret environment variable with the key
CONNECTION_STRING
and the connection string provided by Mongo Atlas. - Enter the port 3000, as this is the one we exposed from the server.
- Name the service, for example
main
.
Once you click on "Create App", Koyeb will take care of deploying your application in just a few seconds. Koyeb will return a public URL to access the app.
Good job! We now have a blog app that is live! Your application now benefits from built-in continuous deployment, global load balancing, end-to-end encryption, its own private network with service mesh and discovery, autohealing, and more.
If you would like to look at the code for this sample application, you can find it here.
Conclusions
In this first part of the series of the MERN web apps series, we built the basic blocks of an online blog application. We initially set up a MongoDB Atlas database, created an Express API server to fetch the data and a React client to show the data to the users.
There are several enhancements we could add on the client-side such as form validation, code refactoring, and more. We will see you soon on the second part where you are going to explore the search abilities of Mongo Atlas.
Since we deployed the application on Koyeb using git-driven deployment, each change you push to your repository will automatically trigger a new build and deployment on the Koyeb Serverless Platform. Your changes will go live as soon as the deployment passes all necessary health checks. In case of a failure during deployment, Koyeb maintains the latest working deployment in production to ensure your application is always up and running.
If you have any questions or suggestions to improve this guide, feel free to reach out to us on Slack.
Posted on June 17, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.