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

This tutorial will explore how we can achieve authentication using `next.js` and `passport.js`, while managing state with swr.

This is a rewrite of our previous version, which reflects the newest commit of nextjs-mongodb-app.

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

Github repo

Demo

About nextjs-mongodb-app project

nextjs-mongodb-app is a Full-fledged serverless app made with Next.JS and MongoDB

Different from many other Next.js tutorials, this:

  • Does not use the enormously big Express.js
  • Supports serverless
  • Using Next.js v9 API Routes w/ middleware

For more information, visit the Github repo.

Getting started

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. We will use the browser's fetch API. If you are to support older browsers, consider using a polyfill like:

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!

Middleware

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

We can use Middleware in Next.js by using next-connect. Beside middleware, next-connect also allows us to do method routing.

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/database.js:

import { MongoClient } from 'mongodb';

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

export default async function database(req, res, next) {
  if (!client.isConnected()) await client.connect();
  req.dbClient = client;
  req.db = client.db(process.env.DB_NAME);
  await setUpDb(req.db);
  return next();
}

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.dbClient. 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

Update 8/12/2020: An earlier version uses next-session, but this has been replaced with express-session. See #92

With next-connect, it is possible to use express-session as our session middleware.

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

npm i next-session connect-mongo

Creating middlewares/session.js:

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

const MongoStore = connectMongo(session);

export default function sessionMiddleware(req, res, next) {
  const mongoStore = new MongoStore({
    client: req.dbClient,
    stringify: false,
  });
  return session({
    secret: process.env.SESSION_SECRET,
    store: mongoStore,
  })(req, res, next);
}

Email/Password authentication using Passport.js

We will use Passport.js for authentication.

npm i passport

We will initialize our Passport instance inside lib/passport.js:

import passport from 'passport';
import bcrypt from 'bcryptjs';
import { Strategy as LocalStrategy } from 'passport-local';
import { ObjectId } from 'mongodb';

passport.serializeUser((user, done) => {
  done(null, user._id.toString());
});

// passport#160
passport.deserializeUser((req, id, done) => {
  req.db
    .collection('users')
    .findOne(ObjectId(id))
    .then((user) => done(null, user));
});

passport.use(
  new LocalStrategy(
    { usernameField: 'email', passReqToCallback: true },
    async (req, email, password, done) => {
      const user = await req.db.collection('users').findOne({ email });
      if (user && (await bcrypt.compare(password, user.password))) done(null, user);
      else done(null, false)
    },
  ),
);

export default passport;

Our passport.serializeUser function will serialize the user id into our session. Later we will use that id to get our user object in passport.deserializeUser.

We will also use passport-local for email/password authentication. We first find the user using the email req.db.collection('users').findOne({ email }). We then compare the password await bcrypt.compare(password, user.password). If everything matches up, we resolve the user via done(null, user).

Common middleware

Sometimes, we use a combination of middleware repeatedly. One way to optimize our code is to create a "sub-application" that includes such common middleware. For example, for most endpoints I need access to the database as well as the session.

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

import nextConnect from 'next-connect';
import database from './database';
import session from './session';

const middleware = nextConnect();

middleware
  .use(database)
  .use(session)
  .use(passport.initialize()) // passport middleware handles authenthentication, which populates req.user
  .use(passport.session());

export default middleware;

This "sub-application" will be reused later.

User state management

Endpoint to get current user

Let's have an endpoint that fetches the current user. I will have it in /api/user.

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

import nextConnect from 'next-connect';
import middleware from '../../../middlewares/middleware';
import { extractUser } from '../../../lib/api-helpers';

const handler = nextConnect();
handler.use(middleware);
handler.get(async (req, res) => res.json({ user: extractUser(req) }));

export default handler;

We simply return req.user, which is populated by our passport.deserializeUser function.

Below is our extractUser function:

export function extractUser(req) {
  if (!req.user) return null;
  // take only needed user fields to avoid sensitive ones (such as password)
  const {
    name, email, bio, profilePicture, emailVerified,
  } = req.user;
  return {
    name, email, bio, profilePicture, emailVerified,
  };
}

It returns onlt the needed user fields. For example, we do not want to return user.password,

State management using swr.

SWR is a React Hooks library for remote data fetching.

We will use swr for state management.

npm i swr

To make it easier, we will create a hook called useUser from useSWR that returns our user data. Create /lib/hooks.jsx:

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then((r) => r.json());

export function useUser() {
  const { data, mutate } = useSWR('/api/user', fetcher);
  const user = data && data.user;
  return [user, { mutate }];
}

useSWR will use our fetcher function to fetch /api/user (and parse it as JSON). This hook will return an array. We will return the user object as the first item in our array. We also include our mutate function in an object of the second item. (This is inspired by Apollo Client react-hooks).

To visualize, the result from /api/user in this format:

{
  "user": {
    "name": "Jane Doe",
    "email": "jane@example.com",
  }
}

This will be the value of data. Thus, we get the user object by const user = data && data.user.

Now, whenever we need to get our user information, we simply need to use useUser.

const [user, { mutate }] = useUser();

Our mutate function can be used to update the user state. For example:

const [user, { mutate }] = useUser();
mutate({ user: { 
  ...user,
  name: 'new name'
})

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.

We will need validator to validate email and bcrypt to encrypt the password:

npm i validator bcryptjs

Let's create /api/users.js:

import nextConnect from 'next-connect';
import isEmail from 'validator/lib/isEmail';
import normalizeEmail from 'validator/lib/normalizeEmail';
import bcrypt from 'bcryptjs';
import middleware from '../../middlewares/middleware';
import { extractUser } from '../../lib/api-helpers';

const handler = nextConnect();

handler.use(middleware); // see how we're reusing our middleware

// POST /api/users
handler.post(async (req, res) => {
  const { name, password } = req.body;
  const email = normalizeEmail(req.body.email); // this is to handle things like jane.doe@gmail.com and janedoe@gmail.com being the same
  if (!isEmail(email)) {
    res.status(400).send('The email you entered is invalid.');
    return;
  }
  if (!password || !name) {
    res.status(400).send('Missing field(s)');
    return;
  }
  // check if email existed
  if ((await req.db.collection('users').countDocuments({ email })) > 0) {
    res.status(403).send('The email has already been used.');
  }
  const hashedPassword = await bcrypt.hash(password, 10);
  const user = await req.db
    .collection('users')
    .insertOne({ email, password: hashedPassword, name })
    .then(({ ops }) => ops[0]);
  req.logIn(user, (err) => {
    if (err) throw err;
    // when we finally log in, return the (filtered) user object
    res.status(201).json({
      user: extractUser(req),
    });
  });
});

export default handler;

The handler:

  • validates the email isEmail(email)
  • Check if the email existed by counting its # of occurance req.db.collection('users').countDocuments({ email })
  • hash the password bcrypt.hash(password, 10)
  • insert the user into our database.

After that, we log the user in using passport's req.logIn.

If the user is authenticated, I return our user object (which will be written into our state later). Keep in mind that we're expecting a 201 status code.

pages/signup.jsx: The sign up page

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

import React, { useState, useEffect } from 'react';
import Head from 'next/head';
import Router from 'next/router';
import { useUser } from '../lib/hooks';

const SignupPage = () => {
  const [user, { mutate }] = useUser();
  const [errorMsg, setErrorMsg] = useState('');
  
  // call whenever user changes (ex. right after signing up successfully)
  useEffect(() => {
    // redirect to home if user is authenticated
    if (user) Router.replace('/');
  }, [user]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    const body = {
      email: e.currentTarget.email.value,
      name: e.currentTarget.name.value,
      password: e.currentTarget.password.value,
    };
    const res = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (res.status === 201) {
      const userObj = await res.json();
      // writing our user object to the state
      mutate(userObj);
    } else {
      setErrorMsg(await res.text());
    }
  };

  return (
    <>
      <Head>
        <title>Sign up</title>
      </Head>
      <div>
        <h2>Sign up</h2>
        <form onSubmit={handleSubmit}>
          {errorMsg ? <p style={{ color: 'red' }}>{errorMsg}</p> : null}
          <label htmlFor="name">
            <input
              id="name"
              name="name"
              type="text"
              placeholder="Your name"
            />
          </label>
          <label htmlFor="email">
            <input
              id="email"
              name="email"
              type="email"
              placeholder="Email address"
            />
          </label>
          <label htmlFor="password">
            <input
              id="password"
              name="password"
              type="password"
              placeholder="Create a password"
            />
          </label>
          <button type="submit">Sign up</button>
        </form>
      </div>
    </>
  );
};

export default SignupPage;

What handleSubmit does is to make a POST request to /api/users with our email, password, and name. After that, I also need to show an Error message if needed by changing errorMsg state. We know the request is successful if the status code is 201.

Notice how we use mutate. Since /api/users return the user object on successful sign up, I simply write that data into our user state.

Also, note that I redirect the user if he or she signs up successfully. This is because when the value of user changes (by calling mutate), I will check it in useEffect and call Router.replace.

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:

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

Building the Authentication API

Let's create api/auth.js:

import nextConnect from 'next-connect';
import middleware from '../../middlewares/middleware';
import passport from '../../lib/passport';
import { extractUser } from '../../lib/api-helpers';

const handler = nextConnect();

handler.use(middleware);

handler.post(passport.authenticate('local'), (req, res) => {
  // return our user object
  res.json({ user: extractUser(req.user) });
});

export default handler;

When a user makes a POST request to /api/auth, we simply call passport.authenticate to sign the user in based on the provided email and password.

If the credential is valid, req.user, our user object, will be returned with a 200 status code. We use extractUser again to avoid exposing sensitive fields.

Otherwise, passport.authenticate will returns a 401 unauthenticated.

pages/login.jsx: The login page

Here is our code for pages/login.jsx:

import React, { useState, useEffect } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useUser } from '../lib/hooks';

const LoginPage = () => {
  const router = useRouter();
  const [errorMsg, setErrorMsg] = useState('');
  const [user, { mutate }] = useUser();
  useEffect(() => {
    // redirect to home if user is authenticated
    if (user) router.replace('/');
  }, [user]);

  async function onSubmit(e) {
    e.preventDefault();
    const body = {
      email: e.currentTarget.email.value,
      password: e.currentTarget.password.value,
    };
    const res = await fetch('/api/auth', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (res.status === 200) {
      const userObj = await res.json();
      mutate(userObj);
    } else {
      setErrorMsg('Incorrect username or password. Try again!');
    }
  }

  return (
    <>
      <Head>
        <title>Sign in</title>
      </Head>
      <h2>Sign in</h2>
      <form onSubmit={onSubmit}>
        {errorMsg ? <p style={{ color: 'red' }}>{errorMsg}</p> : null}
        <label htmlFor="email">
          <input
            id="email"
            type="email"
            name="email"
            placeholder="Email address"
          />
        </label>
        <label htmlFor="password">
          <input
            id="password"
            type="password"
            name="password"
            placeholder="Password"
          />
        </label>
        <button type="submit">Sign in</button>
        <Link href="/forgetpassword">
          <a>Forget password</a>
        </Link>
      </form>
    </>
  );
};

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/auth.

The logic is the same. We still write the user object into the state and redirect the user if he or she logs in successfully. Otherwise, we set errorMsg state to show the error on the screen.

Logout

Let's add functionality to the Logout button, which will generally be on our Navbar:

import React from 'react';
import Layout from '../components/layout';

const Navbar = () => {
const [, { mutate }] = useUser();
  const handleLogout = async () => {
    await fetch('/api/auth', {
      method: 'DELETE',
    });
    // set the user state to null
    mutate(null);
  };
  return (
    /* ... */
    <button onClick={handleLogout}>Logout</button>
    /* ... */
  );
};

Looking at the code above, we need a DELETE request handler in api/auth.js:

handler.delete((req, res) => {
  req.logOut();
  res.status(204).end();
});

We also set our user state to null by calling mutate(null).

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 and the demo here. If you find this helpful, cosidering staring the repo to motivate me with development.

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

You are visiting the previous version of this website.

Move to the new site