Next.js and MongoDB full-fledged app Part 1
User authentication (using Passport.js)
Adding the first fundamental functionality for a full-fledged app: User Authentication.
Below are the Github repository and a demo for this project to follow along.
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();}
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();}
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;
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);
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()];
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
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}`, }, }); } };}
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;
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));});
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")); } });};
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" }}
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);}
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" }}
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();
Our mutate
function can be used to update the user state. For example:
const { data: { user } = {} } = useCurrentUser();
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.
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/usershandler.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;
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;
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)
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;
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;
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> /* ... */ );};
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();});
Conclusion
This will be the first step in building a full-fledged app using Next.js and MongoDB.
I hope this has been helpful in working with your Next.js app. Again, check out the repository nextjs-mongodb-app. If you find this helpful, consider giving it a star to motivate me with further development and more content.
Good luck on your next Next.js + MongoDB project!