DEV Community

Cover image for Next.js and MongoDB full-fledged app Part 1: User authentication (using Passport.js)
Hoang
Hoang

Posted on • Updated on • Originally published at hoangvvo.com

Next.js and MongoDB full-fledged app Part 1: User authentication (using Passport.js)

nextjs-mongodb-app is a full-fledged app built with Next.js and MongoDB. Today, I will add our fundamental feature: User Authentication.

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
  • Minimal, no fancy stuff like Redux or GraphQL for simplicity in learning
  • Using Next.js latest features like API Routes or getServerSideProps

For more information, visit the Github repo.

Getting started

Environmental variables

The environment variables should should be placed in .env.local.
See Environment variables.

Required environmental variables for now includes:

  • process.env.MONGODB_URI

Validation library

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

I'm also using ajv to validate the incoming request body.

Password hashing library

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

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 with the familiar .use() syntax. Beside middleware, next-connect also allows us to do method routing via .get(), .post(), etc., so we don't have to write manual if (req.method) checks.

You can even continue with this project without next-connect using the guide API Middlewares, but it might require more code.

Database middleware

We will need to have a middleware that handles the database connection.

import { MongoClient } from "mongodb";

/**
 * Global is used here to maintain a cached connection across hot reloads
 * in development. This prevents connections growing exponentiatlly
 * during API Route usage.
 * https://github.com/vercel/next.js/pull/17666
 */
global.mongo = global.mongo || {};

export async function getMongoClient() {
  if (!global.mongo.client) {
    global.mongo.client = new MongoClient(process.env.MONGODB_URI);
  }
  // It is okay to call connect() even if it is connected
  // using node-mongodb-native v4 (it will be no-op)
  // See: https://github.com/mongodb/node-mongodb-native/blob/4.0/docs/CHANGES_4.0.0.md
  await global.mongo.client.connect();
  return global.mongo.client;
}

export default async function database(req, res, next) {
  if (!global.mongo.client) {
    global.mongo.client = new MongoClient(process.env.MONGODB_URI);
  }
  req.dbClient = await getMongoClient();
  req.db = req.dbClient.db(); // this use the database specified in the MONGODB_URI (after the "/")
  if (!indexesCreated) await createIndexes(req.db);
  return next();
}
Enter fullscreen mode Exit fullscreen mode

I then attach the database to req.db. In this middleware, we first create a "cachable" MongoClient instance if it does not exist. This allows us to work around a common issue in serverless environments where redundant MongoClients and connections are created.

The approach used in this project is to use the middleware function database to attach the client to req.dbClient and the database to req.db. However, as an alternative, the getMongoClient() function can also be used to get a client anywhere (this is the approach used by the official Next.js example and shown MongoDB blog - We choose to use a middleware instead).

Session middleware

*An earlier version of this project uses express-session, but this has been replaced with next-session due its incompatibility with Next.js 11+.

For session management, Redis or Memcached are better solutions, but since we are already using MongoDB, we will use connect-mongo.

We create the session middleware as below (consult next-session documentation for more detail):

import MongoStore from "connect-mongo";
import { getMongoClient } from "./database";

const mongoStore = MongoStore.create({
  clientPromise: getMongoClient(),
  stringify: false,
});

const getSession = nextSession({
  store: promisifyStore(mongoStore),
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    maxAge: 2 * 7 * 24 * 60 * 60, // 2 weeks,
    path: "/",
    sameSite: "strict",
  },
  touchAfter: 1 * 7 * 24 * 60 * 60, // 1 week
});

export default async function session(req, res, next) {
  await getSession(req, res);
  next();
}
Enter fullscreen mode Exit fullscreen mode

Email/Password authentication using Passport.js

We will use Passport.js for authentication.

We will initialize our Passport instance.

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.deserializeUser((req, id, done) => {
  req.db
    .collection("users")
    .findOne({ _id: new 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;
Enter fullscreen mode Exit fullscreen mode

Our passport.serializeUser function will serialize the user id into our session. Later we will use that same id to get our user object in passport.deserializeUser. The reason we have to pass it into ObjectId is because our _id in MongoDB collection is of such type, while the serialized _id is of type string.

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

Authentication middleware

In order to authenticate users, we need three seperate middleware: Our above session, passport.initialize() and passport.session()middleware. passport.initialize() initialize Passport.js, and passport.session() will authenticate user using req.session which is provided by session.

const handler = nc();
handler.use(session, passport.initialize(), passport.session());
handler.get(foo);
handler.post(bar);
Enter fullscreen mode Exit fullscreen mode

However, to avoid retyping the same .use() or leaving any of them out, I grouped three of them into an array:

export const auths = [session, passport.initialize(), passport.session()];
Enter fullscreen mode Exit fullscreen mode

and use it like below:

import { auths } from "@/api-lib/middlewares";

const handler = nc();
handler.use(...auths); // this syntax spread out the three middleware and is equivalent to the original version
Enter fullscreen mode Exit fullscreen mode

Request body validation middleware

It is a good practice to always validate incoming request bodies. Here we write a middleware that validates req.body using ajv.

import Ajv from "ajv";

export function validateBody(schema) {
  const ajv = new Ajv();
  const validate = ajv.compile(schema);
  return (req, res, next) => {
    const valid = validate(req.body);
    if (valid) {
      return next();
    } else {
      const error = validate.errors[0];
      return res.status(400).json({
        error: {
          message: `"${error.instancePath.substring(1)}" ${error.message}`,
        },
      });
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

The function takes in a JSON schema, creates a Ajv validate function, and returns a middleware that makes use of it. The middleware would validate req.body and if there is an error, we immediately return the error with status code 400.

User state management

Endpoint to get the current user

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

In /api/user/index.js, put in the following content:

import nc from "next-connect";
import { database, auths } from "@/api-lib/middlewares";

const handler = nc();
handler.use(database, ...auths);
handler.get(async (req, res) => res.json({ user: req.user }));

export default handler;
Enter fullscreen mode Exit fullscreen mode

We simply return req.user, which is populated by our auths middleware. However, there is a problem. req.user is the whole user document, which includes the password field.

To fix that, we use a MongoDB feature called Projection to filter it out. We made one adjustment to the Passport deserialize function:

passport.deserializeUser((req, id, done) => {
  req.db
    .collection("users")
    .findOne({ _id: new ObjectId(id) }, { projection: { password: 0 } })
    .then((user) => done(null, user));
});
Enter fullscreen mode Exit fullscreen mode

State management using swr

SWR is a React Hooks library for remote data fetching.

We will use swr for state management. I understand basic understandings of swr, but you can always read its documentation.

We first define a fetcher function:

export const fetcher = (...args) => {
  return fetch(...args).then(async (res) => {
    let payload;
    try {
      if (res.status === 204) return null; // 204 does not have body
      payload = await res.json();
    } catch (e) {
      /* noop */
    }
    if (res.ok) {
      return payload;
    } else {
      return Promise.reject(payload.error || new Error("Something went wrong"));
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

This function is an augmentation of fetch (we actually forward all arguments to it). After receiving a response. We will try to parse it as JSON using res.json. Since fetch does not throw if the request is 4xx, we will check res.ok (which is false if res.status is 4xx or 5xx) and manually reject the promise using payload.error.

The reason I return payload.error is because I intend to write my API to return the error as:

{
  "error": {
    "message": "some message"
  }
}
Enter fullscreen mode Exit fullscreen mode

If for some reason, the error payload is not like that, we return a generic "Something went wrong".

useCurrentUser hook

We need a useSWR hook to return our current user:

import useSWR from "swr";

export function useCurrentUser() {
  return useSWR("/api/user", fetcher);
}
Enter fullscreen mode Exit fullscreen mode

useSWR will use our fetcher function to fetch /api/user.

To visualize, the result from /api/user (which we will write in a later section) is in this format:

{
  "user": {
    "username": "jane",
    "name": "Jane Doe",
    "email": "jane@example.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }] = useCurrentUser();
Enter fullscreen mode Exit fullscreen mode

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

const { data: { user } = {} } = useCurrentUser();
Enter fullscreen mode Exit fullscreen mode

Since data is undefined initially, I default it to = {} to avoid the Uncaught TypeError: Cannot read property of undefined error.

User registration

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

Sign Up Page

Building the Signup API

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

Let's create /api/users/index.js:

import { ValidateProps } from "@/api-lib/constants";
import { database, validateBody } from "@/api-lib/middlewares";
import nc from "next-connect";
import isEmail from "validator/lib/isEmail";
import normalizeEmail from "validator/lib/normalizeEmail";
import slug from 'slug';

const handler = nc();

handler.use(database); // we don't need auths in this case because we don't do authentication

// POST /api/users
handler.post(
  validateBody({
    type: "object",
    properties: {
      username: { type: "string", minLength: 4, maxLength: 20 },
      name: { type: "string", minLength: 1, maxLength: 50 },
      password: { type: "string", minLength: 8 },
      email: { type: "string", minLength: 1 },
    },
    required: ["username", "name", "password", "email"],
    additionalProperties: false,
  }),
  async (req, res) => {
    const { name, password } = req.body;
    const username = slug(req.body.username);
    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;
    }
    // check if email existed
    if ((await req.db.collection("users").countDocuments({ email })) > 0) {
      res.status(403).send("The email has already been used.");
    }
    // check if username existed
    if ((await req.db.collection("users").countDocuments({ username })) > 0) {
      res.status(403).send("The username has already been taken.");
    }
    const hashedPassword = await bcrypt.hash(password, 10);

    const user = {
      emailVerified: false,
      profilePicture,
      email,
      name,
      username,
      bio,
    };

    const password = await bcrypt.hash(originalPassword, 10);

    const { insertedId } = await db
      .collection("users")
      // notice how I pass the password independently and not right into the user object (to avoid returning the password later)
      .insertOne({ ...user, password });

    user._id = insertedId; // we attach the inserted id (we don't know beforehand) to the user object

    req.logIn(user, (err) => {
      if (err) throw err;
      // when we finally log in, return the (filtered) user object
      res.status(201).json({
        user,
      });
    });
  }
);

export default handler;
Enter fullscreen mode Exit fullscreen mode

The handler:

  • is passed through our request body validation
  • normalize and validates the email
  • slugify the username using the slug package (since we don't want some usernames to be like "unicode ♥ is ☢")
  • Check if the email existed by counting its # of occurance req.db.collection('users').countDocuments({ email })
  • Check if the username existed by counting its # of occurance req.db.collection('users').countDocuments({ username })
  • 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.

pages/sign-up.jsx: The sign up page

In sign-up.jsx, we will have the following content:

import { fetcher } from "@/lib/fetch";
import { useCurrentUser } from "@/lib/user";
import Link from "next/link";
import { useRouter } from "next/router";
import { useCallback, useRef, useState } from "react";
import toast from "react-hot-toast";

const SignupPage = () => {
  const emailRef = useRef();
  const passwordRef = useRef();
  const usernameRef = useRef();
  const nameRef = useRef();

  const { mutate } = useCurrentUser();

  const onSubmit = useCallback(
    async (e) => {
      e.preventDefault();
      try {
        const response = await fetcher("/api/users", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            email: emailRef.current.value,
            name: nameRef.current.value,
            password: passwordRef.current.value,
            username: usernameRef.current.value,
          }),
        });
        mutate({ user: response.user }, false);
        router.replace("/feed");
      } catch (e) {
        console.error(e.message);
      }
    },
    [mutate, router]
  );

  return (
    <>
      <Head>
        <title>Sign up</title>
      </Head>
      <div>
        <h2>Sign up</h2>
        <form onSubmit={onSubmit}>
          <input ref={emailRef} type="email" placeholder="Email Address" />
          <input
            ref={emailRef}
            type="password"
            autoComplete="new-password"
            placeholder="Password"
          />
          <input
            ref={usernameRef}
            autoComplete="username"
            placeholder="Username"
          />
          <input
            ref={usernameRef}
            autoComplete="name"
            placeholder="Your name"
          />
          <button type="submit">Sign up</button>
        </form>
      </div>
    </>
  );
};

export default SignupPage;
Enter fullscreen mode Exit fullscreen mode

What onSubmit does is to make a POST request to /api/users with our email, password, username, name. I use ref to grab the values from the uncontrolled inputs.

If the request comes back successfully, we use SWR mutate to update the current user cache then use router to navigate to the main page.

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)

Login Page

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 { passport } from "@/api-lib/auth";
import nc from "next-connect";
import { auths, database } from "@/api-lib/middlewares";

const handler = nc();

handler.use(database, ...auths);

handler.post(passport.authenticate("local"), (req, res) => {
  res.json({ user: req.user });
});

export default handler;
Enter fullscreen mode Exit fullscreen mode

When a user makes a POST request to /api/auth, we simply call the previously set-up 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.

Otherwise, passport.authenticate will returns a 401 unauthenticated.

pages/login.jsx: The login page

Here is our code for pages/login.jsx:

import { useCallback, useEffect } from "react";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useCallback, useEffect, useRef } from "react";
import { useCurrentUser } from "@/lib/user";

const LoginPage = () => {
  const emailRef = useRef();
  const passwordRef = useRef();

  const { data: { user } = {}, mutate, isValidating } = useCurrentUser();
  const router = useRouter();
  useEffect(() => {
    if (isValidating) return;
    if (user) router.replace("/feed");
  }, [user, router, isValidating]);

  const onSubmit = useCallback(
    async (event) => {
      event.preventDefault();
      try {
        const response = await fetcher("/api/auth", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            email: emailRef.current.value,
            password: passwordRef.current.value,
          }),
        });
        mutate({ user: response.user }, false);
      } catch (e) {
        console.error(e);
      }
    },
    [mutate]
  );

  return (
    <>
      <Head>
        <title>Sign in</title>
      </Head>
      <h2>Sign in</h2>
      <form onSubmit={onSubmit}>
        <input
          ref={emailRef}
          id="email"
          type="email"
          name="email"
          placeholder="Email address"
          autoComplete="email"
        />
        <input
          ref={passwordRef}
          id="password"
          type="password"
          name="password"
          placeholder="Password"
          autoComplete="current-password"
        />
        <button type="submit">Sign in</button>
      </form>
    </>
  );
};

export default LoginPage;
Enter fullscreen mode Exit fullscreen mode

The idea is the same, we grab the values from the inputs and submit our requests to /api/auth. We will update the SWR cache using mutate if the response is successful.

I also set up a useEffect that automatically redirects the user as soon as the SWR cache returns a user.

Sign out

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

import { useCallback } from "react";
import { useCurrentUser } from "@/lib/user";

const Navbar = () => {
  const { data: { user } = {}, mutate } = useCurrentUser();

  const onSignOut = useCallback(async () => {
    try {
      await fetcher("/api/auth", {
        method: "DELETE",
      });
      mutate({ user: null });
    } catch (e) {
      toast.error(e.message);
    }
  }, [mutate]);

  return (
    /* ... */
    <button onClick={onSignOut}>Sign out</button>
    /* ... */
  );
};
Enter fullscreen mode Exit fullscreen mode

We make a DELETE request to /api/auth, and if it is successful, we update the SWR cache using mutate.

The last part is to write a DELETE request handler in api/auth.js:

handler.delete(async (req, res) => {
  await req.session.destroy();
  // or use req.logOut();
  res.status(204).end();
});
Enter fullscreen mode Exit fullscreen mode

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 nextjs-mongodb-app. If you find this helpful, consider staring the repo to motivate me with development.

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

Top comments (3)

Collapse
 
fredestrik profile image
Frédéric Lang

Hi,
I build an app closed to nextjs-mongodb-app with SQL and Bulma CSS.
check it out at nextjs-sql-app.vercel.app
github repo : github.com/Fredestrik/Next.Js-SQL-app

Collapse
 
elsaiedsamaka profile image
Elsaied samaka

Nicce @fredestrik

Collapse
 
itsanishjain profile image
itsanishjain

One of the best project I have looked so far in nextjs thanks for this