Node.js: create a simple social network with ExpressJS

In this article we will see how to implement a basic social network similar to Instagram with ExpressJS.

Requirements

Users will need to be able to register, log in, edit their profile, create posts and follow other users. Upon registration, a welcome email must be sent to them.

Database

We will use MongoDB with two collections: users and posts. We will manage the connection and interaction with the database through the Mongoose module.

The users collection will have this scheme for documents:

'use strict';

const mongoose  = require('mongoose');

const { Schema }  = mongoose;

const UserSchema = new Schema({
    name: String,
    username: String,
    email: String,
    password: String,
    url: { type: String, default: '' },
    image: { type: String, default: 'default.png' },
    description: { type: String, default: '' },
    posts: Array,
    followers: Array,
    following: Array

},{collection: 'users'});

module.exports = mongoose.model('users', UserSchema);

Users will be linked to their posts via the posts array which will contain a series of Object IDs so that posts can be retrieved with the $in operator.

In turn, followers and following will contain a series of Object IDs relating to users.

The posts collection has the following scheme instead:

'use strict';

const mongoose  = require('mongoose');

const { Schema }  = mongoose;

const PostSchema = new Schema({
    description: String,
    image: String,
    user: String,
    date: Date

},{collection: 'posts'});

module.exports = mongoose.model('posts', PostSchema);

The user field will contain the username of the user who created the post. If desired, this value can be replaced with an Object ID type.

Sending emails

We will use NodeMailer and Mailtrap for sending emails under development. Mailtrap configuration is as follows:

mail: {
      from: 'noreply@express.localhost',  
      settings: {  
            host: 'smtp.mailtrap.io',
            port: 2525,
            auth: {
              user: '',
              pass: ''
            }
      }      
}

Enter your credentials that you find in the mailbox you created on Mailtrap.

Registration

After validating the input data with the validator module, we create the user account and send the welcome email.

router.post('/register', async (req, res, next) => {
    const { name, email, username, password, password_confirmation } = req.body;
    const errors = [];

    if(validator.isEmpty(name)) {
        errors.push({
            param: 'name',
            msg: 'Name is a required field.'
        });
    }

    if(!validator.isEmail(email)) {
        errors.push({
            param: 'email',
            msg: 'Invalid e-mail address.'
        });
    }

    if(!validator.isAlphanumeric(username)) {
        errors.push({
            param: 'username',
            msg: 'Invalid username.'
        });
    }

    if(validator.isEmpty(password)) {
        errors.push({
            param: 'password',
            msg: 'Password is a required field.'
        });
    }

    if(password !== password_confirmation) {
        errors.push({
            param: 'password_confirmation',
            msg: 'Passwords do not match.'
        });
    }

    try {
        const usernameExists = await users.countDocuments({ username: username });
        const emailExists = await users.countDocuments({ email: email });

        if(usernameExists === 1) {
            errors.push({
                param: 'username',
                msg: 'Invalid username.'
            });
        }

        if(emailExists === 1) {
            errors.push({
                param: 'email',
                msg: 'Invalid e-mail address.'
            }); 
        }

    } catch(err) {
        res.json({ error: err });
    }

    if(errors.length > 0) {
        res.json({ errors });
    } else {
        const encPwd = crypto.createHash('sha256').update(password).digest('hex');

        const newUser = new users({
            name,
            email,
            username,
            password: encPwd

        });

        const mailer = new Mail({
            from,
            settings
        });

        try {
            await newUser.save();
            await mailer.send({ to: email, subject: 'Welcome to Express Instagram', body: `Welcome ${username}!` });
        } catch(err) {
            res.json({ error: err }); 
        }
        
        

        res.json({ success: true });
    }
});

Login

When the user successfully logs in, a user object is created in the current session.

router.post('/login', async (req, res, next) => {
    const { email, password } = req.body;
    const encPwd = crypto.createHash('sha256').update(password).digest('hex');

    try {
        const user = await users.findOne({ email: email, password: encPwd });
        if(user) {
            req.session.user = user;
            res.json({ success: true, username: user.username });
        } else {
            res.json({ success: false }); 
        }
    } catch(err) {
        res.json({ success: false });
    }
});

Editing the profile

This route has multer as an add-on module to manage the upload of files.

router.post('/profile/:username/edit', async (req, res, next) => {
    if(req.session.user) {
        const { username } = req.params;
        try {
            const upload = new Upload({ 
                filename: 'image', 
                destination: UPLOAD_PATH + 'profiles', 
                newName: crypto.createHash('sha256').update(Date.now().toString()).digest('hex') 
            });

            const uploaded = await upload.save(req, res);

            if(uploaded.done) {
                const { url, description } = uploaded.body;
                const {  file } = uploaded;
                const data = {
                    url,
                    description,
                    image: file.filename
                };
                await users.findOneAndUpdate({ username: username }, { $set: data });

                res.json({ updated: true, username });
            } else {
                res.json({ updated: false });
            }    
        } catch(err) {
            res.json(err); 
        }
    } else {
        res.sendStatus(403); 
    }
});

Creating a post

The creation of a post also involves uploading an image associated with the post, so in this case also the multer module will be used.

router.post('/posts/create', async (req, res, next) => {
    if(req.session.user) {
        try {
            const upload = new Upload({ 
                filename: 'image', 
                destination: UPLOAD_PATH + 'posts', 
                newName: crypto.createHash('sha256').update(Date.now().toString()).digest('hex') 
            });

            const uploaded = await upload.save(req, res);

            if(uploaded.done) {
                const { description } = uploaded.body;
                const {  file } = uploaded;
                const errors = [];

                if(validator.isEmpty(description)) {
                    errors.push({
                        param: 'description',
                        msg: 'Description is a required field.'
                    });
                }

                if(errors.length > 0) {
                    fs.unlinkSync(file.path);
                    res.json({ errors });
                } else {
                    const newPost = new posts({
                        description,
                        image: file.filename,
                        user: req.session.user.username,
                        date: new Date()
                    });

                    newPost.save().then(post => {
                        users.findOneAndUpdate({ _id: req.session.user._id}, { $push: { posts: post._id } }).then(result => {
                            res.json({ created: true, postid: post._id });
                        });
                    });
                }
            } else {
                res.json({ created: false });
            }
        } catch(err) {
            res.json(err);
        }
    } else {
        res.sendStatus(403);
    }    
});

When the document is created, its Object ID is inserted in the posts field of the associated user.

Follow / Unfollow

This feature distinguishes between the user following another user (follower) and the user being followed (following). When the user clicks on "Follow" the client-side code sends the Object IDs of the follower and the following to the route.

In the view in EJS we will have this situation:

<% if(isLoggedIn) { %>
                       <% if(user && user._id != currentUser._id) { %>
                       <% const following = user.following;
                          const action = following.includes(currentUser._id) ? 'unfollow' : 'follow';
                       %>
                        <button class="btn btn-primary ml-4" data-action="<%= action %>" data-follower="<%= user._id %>" data-following="<%= currentUser._id %>" id="follow-btn">Follow</button>
                       <% } %>  
                    <% } %> 

currentUser is the user retrieved from the database, while user is the user present in the session (the user who logged in). If the user is not viewing his profile but that of another user, then the button will be displayed and the action of the button will be follow or unfollow depending on whether the Object ID of the profile displayed is present or not in the following array of the user viewing this profile.

Our route takes into account the action sent via AJAX in this way:

router.post('/follow', async (req, res, next) => {
    const { follower, following, action } = req.body;
    try {
        switch(action) {
            case 'follow':
                await Promise.all([ 
                    users.findByIdAndUpdate(follower, { $push: { following: following }}),
                    users.findByIdAndUpdate(following, { $push: { followers: follower }})
                
                ]);
            break;

            case 'unfollow':
                await Promise.all([ 
                    users.findByIdAndUpdate(follower, { $pull: { following: following }}),
                    users.findByIdAndUpdate(following, { $pull: { followers: follower }})
                
                ]); 
            break;

            default:
                break;
        }

        res.json({ done: true });
        
    } catch(err) {
        res.json({ done: false });
    }
});

Client-side code simply adds the logic of dynamic button text change and follower number increment based on the current action.

var $followBtn = $( "#follow-btn" );

    if( $followBtn.length ) {
        $followBtn.click(function() {
            var data = {
                follower: $followBtn.data( "follower" ),
                following: $followBtn.data( "following" ),
                action: $followBtn.data( "action" )
            };

            $.post( "/follow", data, function( res ) {
              if( res.done ) {  
                var text = $followBtn.data( "action" ) === "follow" ? "Unfollow" : "Follow";
                var count = parseInt( $( "#followers" ).text(), 10 );

                if( text === "Unfollow" ) {
                    $( "#followers" ).text( count + 1 );
                    $followBtn.data( "action", "unfollow" ).text( text );
                } else {
                    $( "#followers" ).text( count - 1 ); 
                    $followBtn.data( "action", "follow" ).text( text );
                }
              }  
            });
        });
    }

Demo

Video

Source code

GitHub

Back to top