Recently, I worked on an interesting project called SpeedBoard which is a real-time board for Agile and Scrum retrospectives. It's the kind of tool we use at work after our Scrum Sprint review to easily share our feedback about the last Sprint.
Since it was a very enriching experience, I thought that I would do a quick tutorial on how to set up a simple chat with the same technology stack which includes: MongoDB, Express, React, Node.js and is also called the MERN stack. I am also using Socket.IO for the real-time engine and Material-UI which is a UI framework for React based on Material Design.
If you don't want to wait until the end of this tutorial, you can already check a preview of the final result, and also check the Github repository if you want to fork it and start to improve it ;)
Prerequisites
In this tutorial, we will use Heroku for hosting our live project and Github for hosting our code and deploying it to Heroku, so make sure you already have an account with them, they both provide a free sign up.
Structure
Before we start, let's have a quick look at the structure of our project. Inside our root folder, we will have 2 subfolders: one called client
which contains the React app and one called server
with our Node.js server:
speedchatapp/
├── client/
├── server/
Let's open our Terminal and create our project folder:
mkdir speedchatapp
cd speedchatapp/
Set up the client
On the client-side, we will use the Create React App (CRA) which provides a very easy way to start building any React SPA.
CRA provides a very simple command to install the app, but first, let's ensure that npx
is using the latest version if you used create-react-app
in the past:
npm uninstall -g create-react-app
Now, let's create our app in our client
folder with this simple command:
npx create-react-app client
This might take a couple of minutes to install all the dependencies, and once you are done, try:
cd client/
npm start
You should now be able to access your app at http://localhost:3000/
That was quick and simple : ) But still pretty far from our final result! We'll come back a little bit later to our React app once the server-side of our project is ready.
Set up the server
Now that we have the skeleton of our client
ready, let's have a look at the backend side.
First, let's create our server
folder at the root of our project and initialize our package.json
file:
mkdir server
cd server/
npm init
A utility will take you through the configuration of the file but you can type Enter for all options for this tutorial.
Now, we will install all the dependencies required for our server (Express, Mongoose and Socket.IO) with the following command:
npm install express mongoose socket.io --save
Then, copy the .gitignore
file from the client
folder to the server
folder to prevent some files and folders to be pushed to our GitHub repository (e.g. /node_modules
folder):
cp ../client/.gitignore ./
We will create the 2 files necessary for our server to work. The first one (Message.js) is the schema of the documents we will keep in our database. We will need 3 information: the name of the user who is posting a message in the chat, the content of its message and a timestamp to know when he posted his message.
server/Message.js
const mongoose = require('mongoose');
const messageSchema = new mongoose.Schema({
content: String,
name: String,
}, {
timestamps: true,
});
module.exports = mongoose.model('Message', messageSchema);
The second one (index.js) is our main file, I won't go too much into details because that would make this tutorial a bit too long, but feel free to ask any question in the comments, I'll be glad to answer them or improve the comments directly in the code if necessary.
server/index.js
const express = require('express');
const app = express();
const http = require('http').Server(app);
const path = require('path');
const io = require('socket.io')(http);
const uri = process.env.MONGODB_URI;
const port = process.env.PORT || 5000;
const Message = require('./Message');
const mongoose = require('mongoose');
mongoose.connect(uri, {
useUnifiedTopology: true,
useNewUrlParser: true,
});
app.use(express.static(path.join(__dirname, '..', 'client', 'build')));
io.on('connection', (socket) => {
// Get the last 10 messages from the database.
Message.find().sort({createdAt: -1}).limit(10).exec((err, messages) => {
if (err) return console.error(err);
// Send the last messages to the user.
socket.emit('init', messages);
});
// Listen to connected users for a new message.
socket.on('message', (msg) => {
// Create a message with the content and the name of the user.
const message = new Message({
content: msg.content,
name: msg.name,
});
// Save the message to the database.
message.save((err) => {
if (err) return console.error(err);
});
// Notify all other users about a new message.
socket.broadcast.emit('push', msg);
});
});
http.listen(port, () => {
console.log('listening on *:' + port);
});
The structure of your project should now look like this:
speedchatapp/
├── client/
│ └── (Several files and folders)
└── server/
├── node_modules/
├── .gitignore
├── index.js
├── Message.js
├── package-lock.json (auto-generated)
└── package.json
Before coming back to our React app to finish our project, let's set up our Heroku hosting and link it to our Github repository to make sure the deployment works fine.
Set up our Heroku hosting
Let's download and install the Heroku CLI to set up everything from our Terminal.
Once downloaded and installed, let's go back to our Terminal and login to our Heroku account:
heroku login
It will open a new tab in your browser and once you are logged in, you can close the browser tab and go back to your Terminal.
Now let's create our new app that will host our project:
heroku create
It will automatically generate an identifier with a URL where you can access your app, it should look like this:
https://sleepy-meadow-81798.herokuapp.com/
You can rename your app if you want something a little bit easier to remember, you can then use it for the rest of this tutorial:
Alright, now we need our MongoDB database to store the chat messages from the users. Let's add the mongolab addon to our app:
heroku addons:create mongolab --app speedchatapp
I used speedchatapp
in the previous command because I renamed my application but you should use the one provided when you created it if you didn't rename it, for example, sleepy-meadow-81798
.
Once created it will show you the name of a variable in green, i.e MONGODB_URI
. Now let's get the configuration URI of our newly created database:
heroku config:get MONGODB_URI
You should see something like this:
mongodb://heroku_123abc:abc123@ds141188.mlab.com:41188/heroku_123abc
Copy this URI, and create a file at the root of your project called .env
with the following content [VARIABLE_IN_GREEN]=[URI]
. It should look like this:
MONGODB_URI=mongodb://heroku_123abc:abc123@ds141188.mlab.com:41188/heroku_123abc
Let's copy one more time the .gitignore
and add the .env
file at the end of it to avoid pushing the credentials of our database to GitHub:
cp server/.gitignore ./
echo '.env' >> .gitignore
During the deployment of our app, we need to tell Heroku how to start our server. It can be done by using a Procfile that we will put at the root of our project. So let's create it and add the command line that will start our server:
echo 'web: node server/index.js' > Procfile
Now let's initialize another package.json
at the root of our project. Same as before, don't worry about all the options, for now, just type Enter at all prompts:
npm init
One last thing we want to do here is to install the npm package called Concurrently that will allow us to run both the server and the client in a single command line during our development mode:
npm install --save-dev concurrently
And finally, in our newly created package.json
at the root of the project, we will add 2 lines in the scripts
section:
"scripts": {
"dev": "concurrently --kill-others \"heroku local\" \"npm run start --prefix ./client\"",
"postinstall": "npm install --prefix ./server && npm install --prefix ./client && npm run build --prefix ./client",
}
The postinstall
command, as you can guess, will be executed after Heroku has finished running the npm install
command at the root of our folder. It's telling Heroku to also run the npm install
command inside our client
and server
folder and will also build our React app for production.
Now, it's time to test it, go to the root of your project and type:
npm run dev
This will launch the server and our React app in development mode, and it should open a window in your browser with the previous landing page of our React app.
In your terminal, you should see something like this:
> concurrently --kill-others "heroku local" "npm run start --prefix ./client"
[1]
[1] > react-scripts start
[1]
[0] [OKAY] Loaded ENV .env File as KEY=VALUE Format
[0] 12:16:15 PM web.1 | listening on *:5000
[1] Starting the development server...
[1]
[1] Compiled successfully!
[1]
[1] You can now view client in the browser.
[1]
[1] Local: http://localhost:3000/
[1] On Your Network: http://192.168.0.10:3000/
[1]
[1] Note that the development build is not optimized.
[1] To create a production build, use npm run build.
Note: we are using the same database for both Dev and Live mode, if you want to use a different database, you can always create another one in Heroku like we have seen before and update your .env
file with the credentials of your new database to make sure it won't interfere with the one in production.
Set up GitHub and link to Heroku
Now, we are going create a new repository on GitHub, and we are going to connect it to Heroku so every time we will merge a Pull Request on the master branch, it will automatically deploy it on Heroku.
Let's create our repository on GitHub. Go to https://github.com/new:
Write down the repository URL that we will use in the next step. Back to our Terminal, in the root folder of our project:
// Initialize the root folder as a Git repository
git init
// Add all the files for the initial commit
git add .
// Commit staged files
git commit -m "Initial commit"
// Set the GitHub remote repository
git remote add origin <repository url>
// Push the local changes to GitHub
git push origin master
Now our code is on GitHub, let's link this repository to our Heroku app.
From the Heroku UI, select your app and click on the Deploy
tab. In the Deployment method
, click on Github
, type your repository name and click on Connect
:
Also, make sure that the "Enable Automatic Deploys" on the master
branch is activated:
It should now look like this:
Now let's trigger a first manual deployment to check that everything is fine. Click on the Deploy Branch
and wait until you see you see Your app was successfully deployed
.
Finally, after clicking on the Open App
button at the top right of the page, you should see the React app on your Heroku hosting.
From now on, after pushing any update to your GitHub repository, you should see the deployment triggered automatically in your Heroku UI:
Finishing the client
Now that the architecture of our project is ready, let's finish our client
React app.
The first thing we will need here is to install our frontend dependencies in the client
folder: Socket.IO for client, Material-UI core and icons:
cd client/
npm install socket.io-client @material-ui/core @material-ui/icons --save
Now in the client/package.json
, add the following proxy
field at the end of the file:
"proxy": "http://localhost:5000"
It will tell the development server to proxy any unknown requests to your server in development. Check the official documentation for more information.
Next, we'll create a config.js
file to tell our app to switch endpoints in case we are on our local machine or live hosting:
client/src/config.js
import pkg from '../package.json';
export default {
development: {
endpoint: pkg.proxy
},
production: {
endpoint: window.location.hostname
}
}
Okay now let's start our local development environment from our root folder:
npm run dev
Last steps
For the last step, either create or update each file below manually or go directly to the GitHub repository to check out the project.
Replace client/src/App.css
:
body {
background: #f5f5f5;
padding: 16px;
}
#chat {
max-height: calc(100vh - 128px);
overflow: scroll;
padding: 16px;
}
.name {
color: rgba(0, 0, 0, 0.54);
}
.content {
margin-bottom: 8px;
}
Replace client/src/App.js
:
import React from 'react';
import config from './config';
import io from 'socket.io-client';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import BottomBar from './BottomBar';
import './App.css';
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
chat: [],
content: '',
name: '',
};
}
componentDidMount() {
this.socket = io(config[process.env.NODE_ENV].endpoint);
// Load the last 10 messages in the window.
this.socket.on('init', (msg) => {
let msgReversed = msg.reverse();
this.setState((state) => ({
chat: [...state.chat, ...msgReversed],
}), this.scrollToBottom);
});
// Update the chat if a new message is broadcasted.
this.socket.on('push', (msg) => {
this.setState((state) => ({
chat: [...state.chat, msg],
}), this.scrollToBottom);
});
}
// Save the message the user is typing in the input field.
handleContent(event) {
this.setState({
content: event.target.value,
});
}
//
handleName(event) {
this.setState({
name: event.target.value,
});
}
handleSubmit(event) {
// Prevent the form to reload the current page.
event.preventDefault();
// Send the new message to the server.
this.socket.emit('message', {
name: this.state.name,
content: this.state.content,
});
this.setState((state) => {
// Update the chat with the user's message and remove the current message.
return {
chat: [...state.chat, {
name: state.name,
content: state.content,
}],
content: '',
};
}, this.scrollToBottom);
}
// Always make sure the window is scrolled down to the last message.
scrollToBottom() {
const chat = document.getElementById('chat');
chat.scrollTop = chat.scrollHeight;
}
render() {
return (
<div className="App">
<Paper id="chat" elevation={3}>
{this.state.chat.map((el, index) => {
return (
<div key={index}>
<Typography variant="caption" className="name">
{el.name}
</Typography>
<Typography variant="body1" className="content">
{el.content}
</Typography>
</div>
);
})}
</Paper>
<BottomBar
content={this.state.content}
handleContent={this.handleContent.bind(this)}
handleName={this.handleName.bind(this)}
handleSubmit={this.handleSubmit.bind(this)}
name={this.state.name}
/>
</div>
);
}
};
export default App;
Create client/src/BottomBar.js
:
import React from 'react';
import { fade, makeStyles } from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import InputBase from '@material-ui/core/InputBase';
import Toolbar from '@material-ui/core/Toolbar';
import ChatIcon from '@material-ui/icons/Chat';
import FaceIcon from '@material-ui/icons/Face';
const useStyles = makeStyles(theme => ({
appBar: {
bottom: 0,
top: 'auto',
},
inputContainer: {
backgroundColor: fade(theme.palette.common.white, 0.15),
'&:hover': {
backgroundColor: fade(theme.palette.common.white, 0.25),
},
borderRadius: theme.shape.borderRadius,
marginLeft: theme.spacing(1),
position: 'relative',
width: '100%',
},
icon: {
width: theme.spacing(7),
height: '100%',
position: 'absolute',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
inputRoot: {
color: 'inherit',
},
inputInput: {
padding: theme.spacing(1, 1, 1, 7),
width: '100%',
},
}));
export default function BottomBar(props) {
const classes = useStyles();
return (
<AppBar position="fixed" className={classes.appBar}>
<Toolbar>
<div className={classes.inputContainer} style={{maxWidth: '200px'}}>
<div className={classes.icon}>
<FaceIcon />
</div>
<InputBase
onChange={props.handleName}
value={props.name}
placeholder="Name"
classes={{
root: classes.inputRoot,
input: classes.inputInput,
}}
inputProps={{ 'aria-label': 'name' }}
/>
</div>
<div className={classes.inputContainer}>
<form onSubmit={props.handleSubmit}>
<div className={classes.icon}>
<ChatIcon />
</div>
<InputBase
onChange={props.handleContent}
value={props.content}
placeholder="Type your message..."
classes={{
root: classes.inputRoot,
input: classes.inputInput,
}}
inputProps={{ 'aria-label': 'content' }}
/>
</form>
</div>
</Toolbar>
</AppBar>
);
}
Every time you update the code, you should see the project at http://localhost:3000 automatically reload with the last changes.
Finally, let's push our latest update to GitHub to trigger a new deployment on our live project:
git add .
git commit -m "Final update"
git push origin master
Et voilà, Bob's your uncle! Our chat is now finished and ready: https://speedchatapp.herokuapp.com/
If you have any question, feel free to ask in the comments, I'll be glad to answer it and improve this tutorial. And feel free to fork the project to improve it ;)
Top comments (32)
I'm unable to deploy the master branch. I'm getting an error in the build.
npm ERR! code ENOENT
npm ERR! syscall open
npm ERR! path /tmp/build_4120397c_/client/package.json
npm ERR! errno -2
npm ERR! enoent ENOENT: no such file or directory, open '/tmp/build_4120397c_/client/package.json'
npm ERR! enoent This is related to npm not being able to find a file.
npm ERR! enoent
-----> Build failed
! Push rejected, failed to compile Node.js app.
! Push failed
Hi , I faced the same issue , but in my case there was a .git folder present in my client folder which was preventing the deploy . just delete that .git folder and reinitialise your repository and the build will automatically start on heroku.
Hello, I have the same kind of issue first the same as syedabra003 but I added the node version to my package.json and then the error switched now to this one. I'm at the early step of the guide, when trying manual deploy with Heroku, I started from scratch two times but still the same result, 'npm run dev' runs fine on local so if any clues ? Thanks !
Hi, how do you reproduce the error exactly? Which command do you try to execute? Thanks
Hey,
I followed every step in the post. This output is occurred when I entered 'npm run dev' in terminal.
In case anybody else runs into the same issue, I think the client's package "name" needs to be "client", in case you copy in your own react app... Also, Procfile should not have ' ' around the 'web: ... ' part. Not 100% if that's what solved it for me, but oh well.
Hi Drybone,
Yes, the Procfile should not include the single quote, I am using macOS and the command I wrote in the article is not adding the quote. Maybe it does under another OS? Anyway, the file should be like this:
github.com/armelpingault/speedchat...
And what do you mean exactly by
the client's package "name" needs to be "client"
?I am not sure I understand :)
yes removing the single quotes solved this issue for me.
In server/Message.js file:
const messageSchema = new mongoose.Schema({
content: String,
name: String,
}, {
timestamps: true, -----> this line
});
Is timestamp true by default?
I tried _id.getTimestamp() on a MongoDB _id in which "timestamp: true" is not passed as a parameter, but is still returning the timestamp.
So, is it truly necessary or does it have any other use other than storing the time of creation?
timestamp : true, creates a createdAt and updatedAt field while your inserting documents in the document itself, by default it is not added.
The latter getTimestamp() is a function which finds created date. So there is a difference.
I think you interpreted my doubt in the wrong way.
I didn't pass "timestamps: true" as a parameter. And still, it returned the createdAt field when I tried "_id.getTimestamp()". Then what is the use of passing "timestamps: true" as a parameter?
Lets take a sample model for a signup in mongoose -->
var userSchema = new Schema({
email : String,
password : String,
fullName : String,
userName : {
type : String,
unique : true
}
})
This piece of code will create a mongodb document of this format -->
{
"id" : ObjectId("5eac7f0101dce40f15a97e8d"),
"email" : "asd@asd.com",
"userName" : "hv98",
"fullName" : "asd",
"password" : "asd",
"_v" : 0
}
Notice this doesn't have the createdAt and updatedAt fields
Now a sample model with the timestamp true field -->
var imageSchema = new Schema({
username : String,
description : String,
imagePath : {
type : String
},
comments : [commentSchema],
likes : [String],
nsfw : {
type : Boolean,
default : false
}
},{
timestamps : true
})
A document from this model would look like this -->
"id" : ObjectId("5eb02f999a15002d41f83e14"),
"likes" : [
"hv98"
],
"nsfw" : false,
"username" : "hv98",
"description" : "d",
"imagePath" : "1588604825052IMG_3265.JPG",
"comments" : [
{
"_id" : ObjectId("5eb1581ff810f83199fca925"),
"username" : "hv98",
"comment" : "dd",
"updatedAt" : ISODate("2020-05-05T12:12:15.736Z"),
"createdAt" : ISODate("2020-05-05T12:12:15.736Z")
}
],
"createdAt" : ISODate("2020-05-04T15:07:05.068Z"),
"updatedAt" : ISODate("2020-05-05T12:20:37.408Z"),
"_v" : 0
}
Now if you notice this document has a field called createdAt and updatedAt which was not the case in the earlier one
So when you use _id.getTimestamp() you get the timestamp but it is not a field which is already present in the document but something which the function does and if you have the timestamp : true then this is a field in the document and doesn't require an extra function to be called.
I hope this can settle the difference.
Edit -- **
**One of the uses of the createdAt field is displaying the documents in ascending or descending order.
eg code -->
Image.find({}).sort({ createdAt: -1 }).exec(function(err, docs) {
if(err) console.log(err);
res.json(docs);
});
This returns all the documents and sort them in ascending order that is the latest doc is displayed first and sends it to your client.
Amazing explanation Harsh. This cleared all my doubts.
Thanks for the reply Harsh ;)
Can you please explain why you use socket.broadcast.emit for your 'push' event? It seems like socket.emit would work just fine but it doesn't. I've read this cheat sheet and it doesn't seem to explain why it wouldn't work:
socket.io/docs/v3/emit-cheatsheet/...
Hi Jeff, you might be right, I didn't test it with socket.emit, but it could be a mistake on my side ;)
Thanks for the guide. I needed to install 'dotenv' and add require('dotenv').config(); to the top of index.js so that I could run it locally. I also needed to add this since I was using socket.io v3.0+:
const io = require('socket.io')(http, {
cors: {
origin: "localhost:3000",
methods: ["GET", "POST"]
}
});
To avoid the CORS errors
socket.io/docs/v3/migrating-from-2...
Hi I have an issue to share with you guys. I got an issue if I wrap the with . You will have your code inside setState run twice. The possible way to fix this is to move the logic outside of setState. I have fix this two setState in App.js.
in componentDidMount -> I moved the msg.reverse() outside setState
and in handleSubmit -> I moved the this.socket.emit function call outside. preventing from emit the message twice
Hope it can help. Thank you Armel.
Hi Yodi, thanks a lot, I have updated the source code on Github and in the article ;)
I tried this on my localhost but here I got
"GET http://localhost:5000/socket.io/?EIO=4&transport=polling&t=NMdKy4- net::ERR_FAILED"
"Access to XMLHttpRequest at 'http://localhost:5000/socket.io/?EIO=4&transport=polling&t=NMdKy4-' from origin 'localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource."
If you're using socket.io v3.0+ you need to add this to index.js in the server (replace old code with this):
const io = require('socket.io')(http, {
cors: {
origin: "localhost:3000",
methods: ["GET", "POST"]
}
});
socket.io/docs/v3/migrating-from-2...
How to create a simple and beautiful chat with MongoDB, Express, React and Node.js (MERN stack) is an interesting topic. Get some important points which I didn't know before. Thanks for sharing it. If you want to know about the MERN Stack course then visit: cetpainfotech.com/technolgy/mern-s...
hey , mlab is discontinued and im completely new to heroku . which addon should be used now?
Hi Prachita, you can find a free development solution on mongodb.com/, this is what I am using right now ;)
After using that package.json file will be created I heard but I am unable to create if you an idea or suggestion where I could do a mistake
{
“name”: “test”,
“version”: “1.0.0”,
“description”: “”,
“main”: “index.js”,
“scripts”: {
“test”: “echo \”Error: no test specified\” && exit 1"
},
“author”: “”,
“license”: “ISC”
}
CETPA- app directory is now set
Some comments may only be visible to logged-in visitors. Sign in to view all comments.