How I build a full-fledged app with Next.js and MongoDB Part 1: User authentication

How I build a full-fledged app with Next.js and MongoDB Part 1: User authentication

Update: I have replaced argon2 with bcryptjs for compatibility.

For the last month, I have been struggling with implementing an authentication system for my next project. It is part of an organization that I found to tackle problems in the community with code.

The project is an online game that promotes good behaviors, so it needs to have a way for users to login.

I am using Next.js, a React framework, and MongoDB as my database.

I used to have an Express backend as a Next.js custom server, where I (lazily) use PassportJS to handle authentication. I realized that I did not much control of my APIs doing so. Therefore, I decided to go for my authentication system.

A possible counter-argument is that I should not reinvent the wheel. Still, I wanted exploration and challenges, so I took the path.

Most of the solutions I found online are either relied on 3rd party services (Google, Facebook, Identity as a Service) or half-baked without much thought into the real-life application. Thus, I decide to make my own.

Below are the Github repository and a demo for this project to follow along.

Github repo

Demo (Fixed)

About nextjs-mongodb-app project

nextjs-mongodb-app is a full-fledged app built with Next.JS and MongoDB. Most tutorials on the Internet are either half-baked or not production-ready. This project aims to fix that.

This project goes even further and attempts to integrate top features as seen in real-life apps, making it a full-fledged app.

For more information, visit the Github repo.

Getting started

Typical tutorials will tell you do to npm install next or whatsoever, but I trust you to be an experienced developer already.

I will start by getting right into scaffolding my application.

Environmental variables

This project retrieves the variable from process.env. You will need to implement your strategy. Some of the possible solutions are:

Required environmental variables in this project includes:

  • process.env.MONGODB_URI

Request library

This project will need to have a request library to make requests to API. Feel free to pick your own. Here are some suggestions:

Validation library

I'm using validator for validation, but feel free to use your library or write your check.

Password hashing library

Password must be hashed. Period. There are different libraries out there:

And, no MD5, SHA1, or SHA256, please!

Mongoose... I mean MongoDB

Mongoose is not MongoDB. It is a whole different avenue. I see a lot of tutorials use Mongoose and MongoDB interchangeably, and that is so wrong.

Mongoose is an Object data modeling library. This ensures persistence and integrity of data by defining schemas in your database, which, in my opinion, destroys the purpose of NoSQL.

NoSQL is designed to drop validation and modeling (which is known as ACID). It means any type of data is accepted and future modification of the structure is flexible. On the other hand, Mongoose will require you to have a schema for everything. If I define to my Age field to be Number, trying to save a String to the field will generate an Error.

Be aware that using Mongoose has a significant decrease in performance because data must be validated in every READ and WRITE (which justify dropping ACID).

In case you are going to use Mongoose, here is the schema I have for User:

const UserSchema = new mongoose.Schema({
  email: {
    type: String,
    trim: true,
    minlength: 1,
    unique: true,
    index: true,
  },
  emailVerified: {
    type: Boolean,
  },
  password: {
    type: String,
    minlength: 6,
  },
  name: {
    type: String,
  },
 });
 
 export default mongoose.models.User || mongoose.model('User', UserSchema);

Building the full-fledged authentication.

Response schema

measure twice, cut once

I just cannot stress that enough. I would like to see a persistency in my APIs' responses. Thus, I come up with this schema - a standard response for every APIs:

{
  "status": "ok / error",
  "message": "a user-readable message",
  "data": "<payload object>"
}

Feel free to have your own. For some inspirations, see this Stackoverflow discussion.

If I do not have this planned out, I may run into several problems. Imagine one of my endpoints responses with:

{
  "success": true,
  "msg": "successful stuff",
}

...while another one responses with:

{
  "error": false,
  "text": "ok",  
}

I will have to write two different checks and tries to display the messages using two different field:

if (response.success === true || response.error === false) {
  alert(response.msg || response.text);
}

It will get bad quick especially when the endpoints grow in number and get undocumented.

Middlewares

You may be familiar with the term middleware if you have an ExpressJS background.

The way to use Middlewares in Next.js is to take the original handler (API Route function of (req, res)) an as argument and return a new handler with additional functionality.

Database middleware

We will need to have a middleware that handles database connection since we do not want to call out the database in every route.

Creating middlewares/withDatabase.js:

import { MongoClient } from 'mongodb';

const client = new MongoClient(process.env.MONGODB_URI, { useNewUrlParser: true });

const withDatabase = handler => (req, res) => {
  if (!client.isConnected()) {
    return client.connect().then(() => {
      req.db = client.db('nextjsmongodbapp');
      return handler(req, res);
    });
  }
  req.db = client.db('nextjsmongodbapp');
  return handler(req, res);
};
export default withDatabase;

What happens is that I first only attempt to connect if the client is not connected.

I then attach the database to req.db and client to req.mongoClient. The client can be reused later in our session middleware.

It is not recommended to hard-code your MongoDB URI or any other secure variable. I'm getting it via process.env.

Session middleware

Session is one of the crux elements in our project. In ExpressJS, we can use express-session. In Next.js, a session middleware to use is my next-session. You can go ahead and install it or use/make your middleware.

npm install next-session

Note: At the moment, next-session does not have any native session store, but it is compatible with express-session session stores by setting storePromisify to true.

The default store is MemoryStore, which is not production-ready. For now, we can use connect-mongo since we are already using MongoDB:

Creating middlewares/withSession.js:

import session from 'next-session';
import connectMongo from 'connect-mongo';

const MongoStore = connectMongo(session);

const withSession = handler => session.withSession(handler, {
  store: new MongoStore({ url: process.env.MONGODB_URI }),
});

export default withSession;

MongoStore also requires session. Our session middleware is next-session.

Global middleware

This is my approach to middleware, I'm going to quote it from next-session.

In reality, you would not want to wrap session() around handler in every function. You may run into a situation where the configuration of one session() is different from others. One solution is to create a global middleware.

Simply create middlewares/withMiddleware.js and include our other middlewares:

import withDatabase from './withDatabase';
import withSession from './withSession';

const middleware = handler => withDatabase(withSession(handler));

export default middleware;

It is important to know that the order in withDatabase(withSession(handler)) is matter. Later we will add a middleware called withAuthentication which make use of our database. Thus, we will have withAuthentication inside withDatabase and inside withSession or the database will not be ready and the session will not exist then.

components/layout: The Layout

This part is optional. This is where you can include your Header.jsx to have it on every page as long as you wrap the pages with <Layout>.

I'm going to add some styles using Next.js styled-jsx.

import React from 'react';

export default ({ children }) => (
  <>
    <style jsx global>
      {`
        * {
          box-sizing: border-box;
        }
        body {
          color: #4a4a4a;
          background-color: #f8f8f8;
        }
        input {
          width: 100%;
          margin-top: 1rem;
          padding: 1rem;
          border: none;
          background-color: rgba(0, 0, 0, 0.05);
        }
        button {
          color: #ecf0f1;
          margin-top: 1rem;
          background: #009688;
          border: none;
          padding: 1rem;
        }
      `}

    </style>
    { children }
  </>
);

User registration

Let's start with the user registration since we need at least a user to work with.

Building the Signup API

Let's say we sign the user up by making a POST request to /api/users with a name, an email, and a password.

Everything in the /api is an API Route, a new addition to Next.js 9. Each has to export a function that take two arguments req and res. Here is the content for our users.js:

import isEmail from 'validator/lib/isEmail';
import * as argon2 from 'argon2';
import withMiddleware from '../../middlewares/withMiddleware';

const handler = (req, res) => {
  if (req.method === 'POST') {
    const { email, name, password } = req.body;
    if (!isEmail(email)) {
      return res.send({
        status: 'error',
        message: 'The email you entered is invalid.',
      });
    }
    return req.db.collection('users').countDocuments({ email })
      .then((count) => {
        if (count) {
          return Promise.reject(Error('The email has already been used.'));
        }
        return argon2.hash(password);
      })
      .then(hashedPassword => req.db.collection('users').insertOne({
        email,
        password: hashedPassword,
        name,
      }))
      .then((user) => {
        req.session.userId = user.insertedId;
        res.status(201).send({
          status: 'ok',
          message: 'User signed up successfully',
        });
      })
      .catch(error => res.send({
        status: 'error',
        message: error.toString(),
      }));
  }
  return res.status(405).end();
};

export default withMiddleware(handler);

The function validates the email, hash the password, and insert a user into the database. After that, we set userId in the session to the newly created object's id.

You can see that I first check that the method is POST before proceeding. If it is not, I return status code 405 Method Not Allowed.

If the user is created, I set req.session.userId to the created object id. I will explore it later.

Also note that in each case of error, I simply return a rejection with Error object. The rejection will be caught at .catch(), where I send back a response with the error message via .toString() (Error is an Error object, thus needed to be converted to a string).

pages/signup.jsx: The sign up page

In signup.jsx, we will have the following content:

import React, { useState } from 'react';
import axioswal from 'axioswal';
import Layout from '../components/layout';
import redirectTo from '../lib/redirectTo';

const SignupPage = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (event) => {
    event.preventDefault();
    axioswal
      .post('/api/users', {
        name,
        email,
        password,
      })
      .then((data) => {
        if (data.status === 'ok') {
          redirectTo('/');
        }
      });
  };

  return (
    <Layout>
      <div style={{ margin: '4rem' }}>
        <h1>Sign up</h1>
        <form onSubmit={handleSubmit}>
          <div>
            <input
              type="text"
              placeholder="Your name"
              value={name}
              onChange={e => setName(e.target.value)}
            />
          </div>
          <div>
            <input
              type="email"
              placeholder="Email address"
              value={email}
              onChange={e => setEmail(e.target.value)}
            />
          </div>
          <div>
            <input
              type="password"
              placeholder="Create a password"
              value={password}
              onChange={e => setPassword(e.target.value)}
            />
          </div>
          <button type="submit">
            Sign up
          </button>
        </form>
      </div>
    </Layout>
  );
};

export default SignupPage;

Awesome, let's start up our dev server, navigate to /signup and check it out. Try sign up with a creative imaginary email and a badass password.

full-fledged app with Next.js and MongoDB: Signup 1

Also, try to:

  • Sign up again with the same email.
  • Entered some invalid email.

Does it show the error messages? If yes, great! Let's review what you have just done.

In case you are unfamiliar with Hook, I use the React State Hook useState. In every textbox, I set its value to its corresponding state variable (name, email, password), and when it changes (onChange), I call the the method (setName, setEmail, setPassword) and apply its new value (via e.target.value).

Looking at onSubmit={handleSubmit}, we can see that the submission (either via a button or Enter) will call the function handleSubmit, where we are going to add our login flow. event.preventDefault() prevents the form to be submitted (to the current page, which causes the page to rerender).

What handleSubmit should do beside preventDefault() is to make a POST request to /api/users. After that, I also need to show an Error or Success message to the user.

I use my axioswal, which make the Axios request, process the response, and show an sweetalert2 dialog based on the response.

npm install axioswal

If you are using a different request library or want to handle it on your own, feel free to do so.

Also, note that I redirect the user if he or she signs up successfully. I use a function defined at lib/redirectTo.js:

import Router from 'next/router';

export default function redirectTo(destination, { res, status } = {}) {
  if (res) {
    res.writeHead(status || 302, { Location: destination });
    res.end();
  } else if (destination[0] === '/' && destination[1] !== '/') {
    Router.push(destination);
  } else {
    window.location = destination;
  }
}

Taken from a Github snippet, but I forgot where it was. If you know, tell me :(

User authentication

Now that we have one user. Let's try to authenticate the user. We actually did authenticate the user when he or she signs up:

req.session.userId = user.insertedId;

Let's see how we can do it in /login, where we make a POST request to /api/authenticate.

Building the Authentication API

Let's create api/authenticate.js:

import * as argon2 from 'argon2';
import withMiddleware from '../../middlewares/withMiddleware';

const handler = (req, res) => {
  if (req.method === 'POST') {
    const { email, password } = req.body;

    return req.db.collection('users').findOne({ email })
      .then((user) => {
        if (user) {
          return argon2.verify(user.password, password)
            .then((result) => {
              if (result) return Promise.resolve(user);
              return Promise.reject(Error('The password you entered is incorrect'));
            });
        }
        return Promise.reject(Error('The email does not exist'));
      })
      .then((user) => {
        req.session.userId = user._id;
        return res.send({
          status: 'ok',
          message: `Welcome back, ${user.name}!`,
        });
      })
      .catch(error => res.send({
        status: 'error',
        message: error.toString(),
      }));
  }
  return res.status(405).end();
};

export default withMiddleware(handler);

The logic is simple, we first look up the email by calling findOne given the email as our query. If the email does not exist we reject and say "The email does not exist". If the email does exist, req.db.collection.findOne returns a document which we refer to as user.

We then try to match the passwords. I call argon2.verify (which hash the received password and compare it with the hashed one) to see if the password we get from the request matches the one in the database. If it does not match, argon2.verify() returns false. We then reject and say "The password you entered is incorrect".

In reality, however, I may not want to have two distinct responses saying if it is the email or the password that is incorrect. It may allow brute-forcing. We can change it by simply changing each Error() object to both say "Your email or password is incorrect".

If it matches, we set userId to req.session similar to Signup, and return a message saying "Welcome back" with the name of the user.

pages/login.jsx: The login page

Here is our code for pages/login.jsx:

import React, { useState } from 'react';
import axioswal from 'axioswal';
import Layout from '../components/layout';
import redirectTo from '../lib/redirectTo';

const LoginPage = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (event) => {
    event.preventDefault();
    axioswal
      .post('/api/authenticate', {
        email,
        password,
      })
      .then((data) => {
        if (data.status === 'ok') {
          redirectTo('/');
        }
      });
  };

  return (
    <Layout>
      <div style={{ margin: '4rem' }}>
        <h1>Log in</h1>
        <form onSubmit={handleSubmit}>
          <div>
            <input
              type="email"
              placeholder="Email address"
              value={email}
              onChange={e => setEmail(e.target.value)}
            />
          </div>
          <div>
            <input
              type="password"
              placeholder="Create a password"
              value={password}
              onChange={e => setPassword(e.target.value)}
            />
          </div>
          <button type="submit">
            Log in
          </button>
        </form>
      </div>
    </Layout>
  );
};

export default LoginPage;

If you look at it closely, I copy the whole thing from our signup.jsx, remove the name field, and change the POST URL to api/authenticate.

The logic is the same. We still redirect the user if he or she logs in successfully.

Start the server and navigate to /login to see our result.

Did it work? If it did, awesome! We have managed to get through most of the project.

Using session to determine user identity

Recall that we set an userId in req.session. What we can do is to have a middleware to look it up in our MongoDB database.

37fk3o

req.user middleware

Go ahead and create middlewares/withAuthentication:

import { ObjectId } from 'mongodb';

const withAuthentication = handler => (req, res) => {
  if (req.session.userId) {
    console.log(req.session.userId);
    return req.db.collection('users').findOne(ObjectId(req.session.userId))
      .then((user) => {
        console.log(user);
        if (user) req.user = user;
        return handler(req, res);
      });
  }
  return handler(req, res);
};

export default withAuthentication;

Include it in our withMiddleware.js:

const middleware = handler => withDatabase(withSession(withAuthentication(handler)));

I mentioned that the order is matter because we need req.session to be ready then.

If there is a req.session.userId, we try to look the id up (making sure convert it into an ObjectId first). If there is a user, we attach it to req. Why? Because we may reuse this user document in other endpoints. Since every endpoint goes through withAuthentication, which available via withMiddleware, we can determine the user by simply referring req.user. This also helps us avoid repetitive codes.

Session endpoint to get current user

Let's have an endpoint that fetches the current user. I will have it /api/session, but you can call it anything like /api/user or /api/users/me.

In /api/session, put in the following content:

import withMiddleware from '../../middlewares/withMiddleware';

const handler = (req, res) => {
  if (req.method === 'GET') {
    if (req.user) {
      const { name, email } = req.user;
      return res.status(200).send({
        status: 'ok',
        data: {
          isLoggedIn: true,
          user: { name, email },
        },
      });
    }
    return res.status(200).send({
      status: 'ok',
      data: {
        isLoggedIn: false,
        user: {},
      },
    });
  }
  return res.status(405).end();
};

export default withMiddleware(handler);

In my opinion, it is appropriate to have the endpoint accepts GET request for fetching current user information.

As you can see, I first see if a user is logged in simply by checking if req.user exists.

If so, I responded saying isLoggedIn: true and response with the user's name and email.

If not, I simply response saying isLoggedIn: false and an empty user object.

User Context

We need to call GET /api/session from somewhere and pass the data across React components. I manage to achieve it using React Context API.

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

Yes, just what we need.

Another similar solution would be - I'm sure you heard this a lot - Redux. However, I believe Redux is overkill and the simpler the better.

Let's create components/UserContext.js:

import React, { createContext, useReducer, useEffect } from 'react';
import axios from 'axios';

const UserContext = createContext();

const reducer = (state, action) => {
  switch (action.type) {
    case 'set':
      return action.data;
    case 'clear':
      return {
        isLoggedIn: false,
        user: {},
      };
    default:
      throw new Error();
  }
};

const UserContextProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, { isLoggedIn: false, user: {} });
  const dispatchProxy = (action) => {
    switch (action.type) {
      case 'fetch':
        return axios.get('/api/session')
          .then(res => ({
            isLoggedIn: res.data.data.isLoggedIn,
            user: res.data.data.user,
          }))
          .then(({ isLoggedIn, user }) => {
            dispatch({
              type: 'set',
              data: { isLoggedIn, user },
            });
          });
      default:
        return dispatch(action);
    }
  };
  useEffect(() => {
    dispatchProxy({ type: 'fetch' });
  }, []);
  return (
    <UserContext.Provider value={{ state, dispatch: dispatchProxy }}>
      { children }
    </UserContext.Provider>
  );
};

const UserContextConsumer = UserContext.Consumer;

export { UserContext, UserContextProvider, UserContextConsumer };

Quite a lot going on here. I suggest reading about Context on React website and heading back here.

We first create a context by calling createContext().

I then create a Reducer (again, I would suggest do some reading first) using React Hook:

const [state, dispatch] = useReducer(reducer, { isLoggedIn: false, user: {} });

where { isLoggedIn: false, user: {} } is the default value and reducer is as defined above:

const reducer = (state, action) => {
  switch (action.type) {
    case 'set':
      return action.data;
    case 'clear':
      return {
        isLoggedIn: false,
        user: {},
      };
    default:
      throw new Error();
  }
};

The value that is returned will be set to the state.

For example, in the clear function, I simply returned empty user object and isLoggedIn: false.

Things get a little bit weird at set.

Reducer does not support async function so I have to implement a little trick. I have a "proxy" function. All reducers will first go through dispatchProxy.

If the reducer requires an async call such as fetch dispatchProxy() will do the asynchronous action and call dispatch() when the action resolves. In particular, after fetching completed, it dispatchProxy() calls dispatch() and update the user context.

If the reducer does not contain an asynchronous call, I simply forward it to the actual dispatch().

Every time I need to fetch the user data (after logging in, or after changing profiles, etc.), I simply call the dispatch(), which is available by simply importing the UserContext. Also notice useEffect(() => {}, []);: I want to fetch in the app first rendering.

Similar to the availability of reducer, we can access the state by importing UserContext too. The state will contains all the user information we need.

Provider

To use Context on child components, we need a ContextProvider at the higher-order components.

<Provider>
  <Child1 />
  <Child 2>
    <Child 3 />
  </Child 2>
</Provider>

Looking at the mapping above, Child 1, Child 2, Child 3 will be able to access the Context. The <Provider> will have this format:

<MyContext.Provider value={/* some value */}>

If you look back to the return value of UserContextProvider in UserContext.jsx, you can see that we achieve that. Therefore, simply import UserContextProvider in that high ordered component.

In Next.js, _app.js is the highest component we have access to. Thus, we are going to have our UserContextProvider imported there.

Creating pages/_app.jsx:

import React from 'react';
import App, { Container } from 'next/app';
import { UserContextProvider } from '../components/UserContext';

class MyApp extends App {
  render() {
    const { Component, pageProps } = this.props;
    return (
      <Container>
        <UserContextProvider>
          <Component {...pageProps} />
        </UserContextProvider>
      </Container>
    );
  }
}

export default MyApp;
Accessing state of UserContext

Open up our pages/index.js and fill in the following:

import React, { useContext } from 'react';
import Link from 'next/link';
import { UserContext } from '../components/UserContext';
import Layout from '../components/layout';

const IndexPage = () => {
  const { state: { isLoggedIn, user: { name } } } = useContext(UserContext);

  return (
    <Layout>
      <div>
        <h1>
          Hello,
          {' '}
          {(isLoggedIn ? name : 'stranger.')}
        </h1>

        {(!isLoggedIn ? (
          <>
            <Link href="/login"><div><button>Login</button></div></Link>
            <Link href="/signup"><div><button>Sign up</button></div></Link>
          </>
        ) : <button>Logout</button>)}

      </div>
    </Layout>
  );
};

export default IndexPage;

Sorry if const { state: { isLoggedIn, user: { name } } } = useContext(UserContext); confuses you as I use some ES6 Syntax. I basically get isLoggedIn and user.name from the state of UserContext.

I also write a conditional rendering. If isLoggedIn === true, I will render the name. Otherwise, I will render "stranger". Also, below the Welcome text, if isLoggedIn === false, I also render the two links to Login and Sign up. Similarly, we render the Logout button if isLoggedIn === true.

Dispatch fetch in UserContext

Remember that we need to dispatch the fetch reducer for everything to work.

Open pages/login.jsx and pages/signup.jsx and include the UserContext. Here is the implementation for pages/login.jsx. Try to do the other one on your own.

axioswal
      .post('/api/authenticate', {
        email,
        password,
      })
      .then((data) => {
        if (data.status === 'ok') {
          //  Fetch the user data for UserContext here
          dispatch({ type: 'fetch' });
          redirectTo('/');
        }
      });

dispatch({ type: 'fetch' }); will call the dispatchProxy(), which make the GET request and call dispatch() to update the UserContext.

Logout: delete userId from session

Let's add functionality to the Logout button in index.jsx:

import React, { useContext } from 'react';
import Link from 'next/link';
import axioswal from 'axioswal';
import { UserContext } from '../components/UserContext';
import Layout from '../components/layout';

const IndexPage = () => {
  const { state: { isLoggedIn, user: { name } }, dispatch } = useContext(UserContext);
  const handleLogout = (event) => {
    event.preventDefault();
    axioswal
      .delete('/api/session')
      .then((data) => {
        if (data.status === 'ok') {
          dispatch({ type: 'clear' });
        }
      });
  };
  return (
    <Layout>
      <div>
        <h1>
          Hello,
          {' '}
          {(isLoggedIn ? name : 'stranger.')}
        </h1>

        {(!isLoggedIn ? (
          <>
            <Link href="/login"><div><button>Login</button></div></Link>
            <Link href="/signup"><div><button>Sign up</button></div></Link>
          </>
        ) : <button onClick={handleLogout}>Logout</button>)}

      </div>
    </Layout>
  );
};

export default IndexPage;

I import dispatch from UserContext. Also, I assign a function to the Logout button that makes a DELETE request to /api/session. If the request is a success, we call the clear reducer to empty the UserContext.

Obvious we need to add a DELETE request handler in api/session.js:

import withMiddleware from '../../middlewares/withMiddleware';

const handler = (req, res) => {
  if (req.method === 'GET') {
    if (req.user) {
      const { name, email } = req.user;
      return res.status(200).send({
        status: 'ok',
        data: {
          isLoggedIn: true,
          user: { name, email },
        },
      });
    }
    return res.status(200).send({
      status: 'ok',
      data: {
        isLoggedIn: false,
        user: {},
      },
    });
  }
  if (req.method === 'DELETE') {
    delete req.session.userId;
    return res.status(200).send({
      status: 'ok',
      data: {
        isLoggedIn: false,
        message: 'You have been logged out.',
      },
    });
  }
  return res.status(405).end();
};

export default withMiddleware(handler);

Just like we set userId to the session when we log in. All I have to do is to delete the userId from the session to log out.

Conclusion

Alright, let's run our app and test it out. This will be the first step in building a full-fledged app using Next.js and MongoDB.

I hope this can be a boilerplate to launch your next great app. Again, check out the repository here. I do accept feature requests. What features should I add next time? Feel free to create an issue and tell me.

I'm not so good at writing, so if there is an issue with the article, please help me improve.

Good luck on your next Next.js + MongoDB project!