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.
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:
- https://github.com/zeit/next.js/tree/canary/examples/with-dotenv
- Setting the environment variables in your service provider (such as now.sh env, or Heroku config var)
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!