DEV Community

Cover image for Get started with the MERN stack: Build a blog with MongoDB Atlas
alisdairbr for Koyeb

Posted on • Updated on • Originally published at koyeb.com

Get started with the MERN stack: Build a blog with MongoDB Atlas

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.

mern schema

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:

Steps

The steps to creating a blog application with a MERN stack and deploying it to production on Koyeb include:

  1. Set up the blog application project
  2. Create a MongoDB Atlas database
  3. Define the blog post model and the article schema
  4. Implement the schema using Mongoose
  5. Configure the blog's API endpoints with Express
  6. Test the API endpoints using Postman
  7. Set up the blog's UI with React, Axios, and reusable components
  8. 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
Enter fullscreen mode Exit fullscreen mode

Move into mongo-blog and setup Express using express-generator:

cd mongo-blog
npx express-generator
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  },
...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  },
...
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  },
...
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:'));
...
Enter fullscreen mode Exit fullscreen mode

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"
+   }
+ }
}

Enter fullscreen mode Exit fullscreen mode

To ensure everything is running as expected, run the application locally:

yarn dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Here is an explanation of what we are doing here:

  1. Require Mongoose and use the Schema class to create PostSchema.
  2. When creating the object PostSchema, we add the fields title, content, author, tags, createdAt.
  3. Instruct PostSchema to automatically add the creation date right before saving the new post inside the database for us.
  4. 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:

  1. GET /api/posts returns all the posts in descending order, from the latest to the earliest.
  2. GET /api/posts/:id returns a single blog post given its id.
  3. POST /api/posts saves a new blog post into the db.
  4. PUT /api/posts/:id updates a blog post given its id.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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 },
  });
});
...
Enter fullscreen mode Exit fullscreen mode

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 || {},
    },
  });
});
...

Enter fullscreen mode Exit fullscreen mode

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 },
  });
});
...
Enter fullscreen mode Exit fullscreen mode

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 },
  });
});
...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

create-blog-post

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:

get-all-posts

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

update-post

If you are unsure whether it was correctly updated, go ahead and call GET /api/posts/post_id again:

get-updated-post

Finally, test that deleting the post works as expected:

delete-post

Run GET /api/posts again and you should get an empty list of posts as result:

get-empty-list-of-posts

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

In a few lines of code we created a decent layout that. Once we implement Home, our home page should look like this:

react-homepage

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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Call the server GET /api/posts endpoint after the first rendering
  2. Store the array posts in the state
  3. 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

react-homepage

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:

  1. When hitting /posts/post_id the client calls the server API to fetch the specific blog post.
  2. The post is stored in the component state.
  3. Using react-boostrap, we create a simple-but-effective UI for the users to read the post.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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' }}>&#8592; Back to Home</Link>
      </Container>
    </>
  );
};

export default Post;
Enter fullscreen mode Exit fullscreen mode

The UI will look like this:

react-post-content

As we will redirect the user to another page when editing the blog post, create the file edit.js inside /pages:

touch edit.js
Enter fullscreen mode Exit fullscreen mode

The UI will show a form filled with the blog post data for title, author, content and tags. Users can

  1. Edit each one of the fields
  2. 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' }}>&#8592; Back to Home</Link>
    </Container>
  );
};

export default Edit;
Enter fullscreen mode Exit fullscreen mode

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:

react-edit-post

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' }}>&#8592; Back to Home</Link>
    </Container>
  );
};

export default Post;
Enter fullscreen mode Exit fullscreen mode

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:

react-create-post

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Name your application, for example mern-blog.
  2. For "Deployment method", choose Github.
  3. Select the git repository and specify the branch where you pushed the code to. In my case, main.
  4. In application configuration, add the build command "yarn build-client" and the start command "yarn start"
  5. Add a Secret environment variable with the key CONNECTION_STRING and the connection string provided by Mongo Atlas.
  6. Enter the port 3000, as this is the one we exposed from the server.
  7. 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.

Top comments (0)