How to add realtime notifications to your Node.js app

Introduction

Realtime notifications are useful in social and other apps where all or some users need to be immediately aware when events they might be interested in occur. With the Web Notifications API (supported by most modern browsers), combined with Pusher Channels realtime messaging model, it’s easy to notify your users almost instantly when something they should know about occurs.

In this tutorial, we’ll demonstrate how we can add realtime notifications to a Node.js app. The app we’ll build is similar to Wikipedia: it shows a list of posts that anyone can read and edit. Unlike Wikipedia, though, the app notifies everyone who’s anywhere in the app whenever you edit a page. (For this reason, we’ll call the app Tattletale) Here’s a preview of the app in action:

tattletale-demo

You can check out the source code of the completed application on GitHub.

Requirements

  • Node.js (version 6.0.0 or higher) and NPM (version 3.8.6 or higher). Get Node here (NPM’s included).
  • MongoDB (version 3.4 or higher). Get it here.

Set up the project

We’ll be using the Express framework. First, install the Express app generator to quickly set up our app:

    npm install express-generator -g

Next, create a new Express app with the view engine set to Handlebars (hbs):

    express --view=hbs tattletale

Then we’ll install our dependencies, and add some more modules we’ll need:

1cd tattletale && npm install
2    npm install --save dotenv faker mongoose pusher

Here’s a breakdown of what each module is for.

  • dotenv: package for loading private configuration variables (namely our Pusher app credentials) from a .env file.
  • faker: module for generating fake data we can seed into our database.
  • mongoose: an ORM for MongoDB that maps our models (JavaScript objects) to MongoDB documents.
  • pusher: library for interacting with the Pusher APIs.

We’ll store posts in a posts collection. A single item in this collection will contain a title and a body. Let’s define our Post model. Create a directory called models , and create a file in it called post.js with he following content:

1let mongoose = require('mongoose');
2    
3    let Post = mongoose.model('Post', {
4        title: String,
5        body: String
6    });
7    
8    module.exports = Post;

Next up, let’s populate our database with some fake posts. Create a file called seed.js in the bin directory, with the following content:

1require('mongoose').connect('mongodb://localhost/tattletale');
2    
3    const faker = require('faker');
4    const Post = require('../models/post');
5    
6    // empty the collection first
7    Post.remove({})
8        .then(() => {
9            const posts = [];
10            for (let i = 0; i < 5; i++) {
11                posts.push({
12                    title: faker.lorem.sentence(),
13                    body: faker.lorem.paragraph()
14                });
15            }
16            return Post.create(posts);
17        })
18        .then(() => {
19            process.exit();
20        })
21        .catch((e) => {
22            console.log(e);
23            process.exit(1);
24        });

Run the seed using node (remember to start your MongoDB server by running mongod first):

    node bin/seed.js

Displaying posts

Let’s implement our views and routes. Our home page will display a list of post titles. Clicking on a title will open the post for viewing. On the single post page, there’ll also be an Edit button that makes the post editable. Let’s get to work!

First, we’ll add our MongoDB connection setup to our app.js, so the connection gets created when our app starts. Above this line:

    module.exports = app;

Add this:

    require('mongoose').connect('mongodb://localhost/tattletale');

Next, let’s write the route that retrieves all posts from the database and pass them to the view. We’ll also write the route that renders a single post. Replace the code in routes/index.js with this:

1const router = require('express').Router();
2    const Post = require('./../models/post');
3    
4    router.get('/', (req, res, next) => {
5        Post.find({}, {title: true}).exec((err, posts) => {
6            res.render('index', { posts });
7        });
8    });
9    
10    router.get('/posts/:id', (req, res, next) => {
11        Post.findOne({ _id: req.params.id }).exec((err, post) => {
12            res.render('post', { post });
13        });
14    });
15    
16    module.exports = router;

Let’s create our views.

Put the following code in the views/layout.hbs file:

1<!DOCTYPE html>
2    <html lang='en'>
3    <head>
4        <title>{{title}}</title>
5        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
6          integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm"
7          crossorigin="anonymous">
8        <!-- jQuery for easy DOM manipulation -->
9        <script src="https://code.jquery.com/jquery-3.3.0.min.js"
10            integrity="sha256-RTQy8VOmNlT6b2PIRur37p6JEBZUE7o8wPgMvu18MC4="
11            crossorigin="anonymous">
12        </script>
13        <!-- Axios for AJAX requests -->
14        <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
15    </head>
16    
17    <body>
18    <div class="container" style="padding: 50px;">
19        {{{body}}}
20    </div>
21    </body>
22    </html>

Add the following to the views/index.hbs file:

1<div class="row">
2        <h2>Posts</h2>
3    </div>
4    <div class="list-group">
5        {{#each posts }}
6            <a href="/posts/{{ this._id }}" class="list-group-item">
7                {{ this.title }}
8            </a>
9        {{/each}}
10    </div>

Place the code below in a file called post.hbs in the views directory:

1<div class="jumbotron jumbotron-fluid">
2        <div class="container">
3            <h1>{{ post.title }}</h1>
4            <p id="post-body">{{ post.body }}</p>
5            <p id="actions">
6                <a class="btn btn-primary btn-lg" href="#" id="edit-btn" role="button">Edit</a>
7            </p>
8        </div>
9    </div>

Editing posts

On the single post page, when a user clicks the Edit button, the post body should become editable, and Save and Cancel buttons should replace the Edit button.

Let’s update our views and routes to support editing of posts. First, in our routes/index.js, the route for saving changes to the post:

1router.post('/posts/:id', (req, res, next) => {
2        Post.findByIdAndUpdate(req.params.id, {body: req.body.body}, (err, post) => {
3                res.send('');
4            });
5    });

Add the following code to the single post view, views/post.hbs:

1<script>
2        var postId = "{{ post._id }}";
3        var postContent;
4        var makeEditable = function (event) {
5            // capture the content of the post, then replace with editable textarea
6            postContent = $("#post-body").html();
7            var editableText = $('<textarea id="editable-post-body" class="form-control" />');
8            editableText.val(postContent);
9            $("#post-body").replaceWith(editableText);
10            editableText.focus();
11    
12            var saveBtn = $('<a class="btn btn-primary btn-lg" id="save-btn" role="button">Save</a>');
13            var cancelBtn = $('<a class="btn btn-info btn-lg" id="cancel-btn" role="button">Cancel</a>');
14    
15            saveBtn.click(function () {
16                // capture the new post content and send to backend
17                postContent = $("#editable-post-body").val();
18                axios.post("/posts/" + postId, { body: postContent });
19                restoreBody();
20            });
21            cancelBtn.click(restoreBody);
22            // replace "Edit" button with "Save" and "Cancel"
23            $("#actions").html([saveBtn, cancelBtn]);
24        };
25    
26        $('#edit-btn').click(makeEditable);
27    
28        var restoreBody = function() {
29            var postBody = $('<p id="post-body"></p>');
30            postBody.html(postContent);
31            $("#editable-post-body").replaceWith(postBody);
32            var editBtn = $('<a class="btn btn-primary btn-lg" href="#" id="edit-btn" role="button">Edit</a>');
33            editBtn.click(makeEditable);
34            $("#actions").html(editBtn);
35        };
36    
37    </script>

At this point, the app is fairly functional. Start the app by running npm start. Visit the app on http://localhost:3000. You should be able to view all posts, view a single post, and edit it (see the GIF below).

tattletale-editing

Adding notfications

Now let’s add the realtime notification capability.

To get started with Pusher Channels, create a free sandbox Pusher account or sign in. Then create an app and obtain your app credentials from the app dashboard. Create a file named .env in the root of your project with the following content:

1PUSHER_APP_ID=YOUR_APP_ID
2    PUSHER_APP_KEY=YOUR_APP_KEY
3    PUSHER_APP_SECRET=YOUR_APP_SECRET
4    PUSHER_APP_CLUSTER=YOUR_APP_CLUSTER

Replace YOUR_APP_ID, YOUR_APP_KEY, YOUR_APP_SECRET, and YOUR_APP_CLUSTERwith your Pusher app ID, app key, app secret and cluster respectively. Then add this line to the top of your app.js. This is to setup dotenv so it pulls environment variables from our .env file:

    require('dotenv').config();

Modify your routes/index.js so the endpoint for editing a post looks like this:

1router.post('/posts/:id', (req, res, next) => {
2        Post.findByIdAndUpdate(req.params.id, {body: req.body.body}, (err, post) => {
3                let Pusher = require('pusher');
4                let pusher = new Pusher({
5                    appId: process.env.PUSHER_APP_ID,
6                    key: process.env.PUSHER_APP_KEY,
7                    secret: process.env.PUSHER_APP_SECRET,
8                    cluster: process.env.PUSHER_APP_CLUSTER
9                });
10    
11                pusher.trigger('notifications', 'post_updated', post, req.headers['x-socket-id']);
12                res.send('');
13            });
14    });

You’ll notice we include a fourth parameter in our call to pusher.trigger. This is the socket ID, a unique identifier that Pusher assigns to each client connection (in this case, every new window where our app is opened). The socket ID is sent from the frontend via an X-Socket-Id header. By passing the socket ID to pusher.trigger, we are ensuring that the client with that ID will not get notified. This is what we want, since that window has already updated its UI.

Let’s add the frontend code that responds to the events. We want to respond in two ways when a post_updated event comes in:

  • A browser notification should pop up. When the notification is clicked, it should open the edited post.
  • On the home page, an Updated badge should also appear next to the list item corresponding to the post that was updated.

Since both pages in our app use this functionality, we’ll place it in within the body tags of views/layout.hbs :

1<script src="https://js.pusher.com/4.1/pusher.min.js"></script>
2    <script>
3        var pusher = new Pusher('your-app-key', { cluster: 'your-app-cluster' });
4    
5        // retrieve the socket ID once we're connected
6        pusher.connection.bind('connected', function () {
7            // attach the socket ID to all outgoing Axios requests
8            axios.defaults.headers.common['X-Socket-Id'] = pusher.connection.socket_id;
9        });
10    
11        // request permission to display notifications, if we don't alreay have it
12        Notification.requestPermission();
13        pusher.subscribe('notifications')
14                .bind('post_updated', function (post) {
15                    // if we're on the home page, show an "Updated" badge
16                    if (window.location.pathname === "/") {
17                        $('a[href="/posts/' + post._id + '"]').append('<span class="badge badge-primary badge-pill">Updated</span>');
18                    }
19                    var notification = new Notification(post.title + " was just updated. Check it out.");
20                    notification.onclick = function (event) {
21                        window.location.href = '/posts/' + post._id;
22                        event.preventDefault();
23                        notification.close();
24                    }
25                });
26    </script>

Remember to replace your-app-id with your Pusher app ID and your-app-cluster with your app cluster.

All done! Start up your MongoDB server by running mongod, serve your app with npm start, and visit your app on http://localhost:3000 in two browser windows. It should work just like the preview shown earlier. Editing a post in one window immediately notifies the other.

Conclusion

We’ve seen how straightforward it is to add realtime push notifications to a web app, thanks to the Web Notifications API and Pusher realtime messaging. Our demo app is a simple example. The same functionality could be used in many real world scenarios (for instance, an internal company tool where realtime updates are important). You can check out the source code of the completed application on GitHub, read up more about Web Notifications here, and dive deeper into Pusher services here.