Published: April 11 2023

Next.js 13 + MongoDB - User Registration and Login Tutorial with Example App

Tutorial built with Next.js 13.2.4, React 18.2.0 and MongoDB

In this tutorial we'll go through an example of how to build a simple user registration, login and user management (CRUD) application with Next.js and MongoDB.

Tutorial contents


Example Next.js + MongoDB Auth App Overview

The example is a full-stack Next.js application that includes a React front-end and Next.js back-end.

Next.js Tutorial Front-End App

The Next.js client app is built with React and contains the following pages:

  • /account/login - public page for logging into the Next.js app.
  • /account/register - public page for registering a new user account with the app.
  • / - secure home page containing a simple welcome message to the logged in user.
  • /users - secure page displaying a list of all users in the Next.js app, with options to add, edit or delete users.
  • /users/add - secure page for adding a new user.
  • /users/edit/[id] - secure page for editing an existing user.

Secure pages are protected by the authCheck() function of the Next.js App Component which redirects unauthenticated users to the login page.

NOTE: Client-side security is more about UX than real security, it isn't difficult to bypass since all the client code is downloaded to the browser and accessible to the user, but the client code doesn't contain any sensitive data and bypassing it won't give you access to the API which requires a valid JWT token to access a secure route.

Next.js Tutorial Back-End API

The Next.js API contains the following routes/endpoints:

  • /api/users/authenticate - POST - public route for authenticating username and password and generating a JWT token.
  • /api/users/register - POST - public route for registering a new user with the Next.js app.
  • /api/users - GET - secure route that returns all users.
  • /api/users/[id] - GET - secure route that returns the user with the specified id.
  • /api/users/[id] - PUT - secure route for updating a user.
  • /api/users/[id] - DELETE - secure route for deleting a user.

Secure routes require a valid JWT token in the HTTP Authorization header of the request.

MongoDB and Mongoose ODM

MongoDB is the database used by the api for storing user data, and the Mongoose ODM (Object Data Modeling) library is used to interact with MongoDB, including defining the schemas for collections, connecting to the database and performing all CRUD operations. For more info on Mongoose see https://mongoosejs.com/.

React Hook Form Library

The forms in the example is built with React Hook Form - a library for building, validating and handling forms in React using React Hooks. I've been using it for my React projects for a while now, I think it's easier to use than the other options available and requires less code. For more info see https://react-hook-form.com.

RxJS

RxJS subjects and observables are used by the user service to store the current user state and communicate between different components in the application. To learn more about using React with RxJS check out React + RxJS - Communicating Between Components with Observable & Subject.

Code on GitHub

The example project is available on GitHub at https://github.com/cornflourblue/next-js-13-mongodb-registration-login-example.


Tools required for this tutorial

To develop and run Next.js + MongoDB applications locally you'll need the following:


Run the Next.js + MongoDB Login Example Locally

Follow these steps to download and run the Next.js 13 + MongoDB Auth App:

  1. Download or clone the Next.js project source code from https://github.com/cornflourblue/next-js-13-mongodb-registration-login-example
  2. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  3. Start the app by running npm run dev from the command line in the project root folder, this will compile the app and start the Next.js server.
  4. Open to the app at the URL http://localhost:3000.

NOTE: You can also start the app directly with the Next.js CLI command npx next dev. For more info on the Next.js CLI see https://nextjs.org/docs/api-reference/cli.

Before running in production

Before running in production make sure you update the secret property in the Next.js config file, it is used to sign and verify JWT tokens for authentication, change it to a random string to ensure nobody else can generate a JWT with the same secret and gain unauthorized access to your API. A quick and easy way is join a couple of GUIDs together to make a long random string (e.g. from https://www.guidgenerator.com/).


Next.js + MongoDB Code Documentation

The project is organised into the following folders:

  • components
    React components used by pages or by other React components. Global components are in the root /components folder and feature specific components are in subfolders (e.g. /components/account, /components/users).
  • helpers
    Anything that doesn't fit into the other folders and doesn't justify having its own folder. Front-end React helpers are in the root /helpers folder and back-end API helpers are in the /helpers/api subfolder.
  • pages
    Pages and API route handlers for the Next.js login tutorial app. The /pages folder contains all routed pages with the route to each page defined by its file name. The /pages/api folder contains all API route handlers which are also routed based on each file name. For more info on Next.js Page Routing and file name patterns see https://nextjs.org/docs/routing/introduction, for API Routing see https://nextjs.org/docs/api-routes/introduction.
  • services
    Services handle all HTTP communication from the React front-end app to the Next.js back-end API, each service encapsulates the API calls for a content type (e.g. users) and exposes methods for performing various operations (e.g. CRUD operations). Services can also perform actions that don't involve HTTP requests, such as displaying and clearing alerts with the alert service.
  • styles
    CSS stylesheets used by the Next.js tutorial app.

Javascript code structure

JavaScript files are organised with export statements at the top so it's easy to see all exported modules when you open a file. Export statements are followed by functions and other implementation code for each JS module.

Barrel files

The index.js files in some folders (e.g. components, helpers, services) are barrel files that re-export all of the modules from that folder so they can be imported using only the folder path instead of the full path to each module, and to enable importing multiple modules in a single import (e.g. import { userService, alertService } from 'services';).

Base URL for imports

The baseUrl is set to "." in the jsconfig.json file to make all import statements (without a dot '.' prefix) relative to the root folder of the project, removing the need for long relative paths like import { userService } from '../../../services';.

Click any of the below links to jump down to a description of each file along with it's code:

 

Account Layout Component

Path: /components/account/Layout.jsx

The account layout component contains common layout code for all pages in the /pages/account folder, it simply wraps the {children} elements in a div with some bootstrap classes to set the width and alignment of all of the account pages. The Layout component is imported by each account page and used to wrap the returned JSX template (e.g. login page, register page).

A useEffect() react hook is used to automatically redirect the user to the home page if they are already logged in.

import { useEffect } from 'react';
import { useRouter } from 'next/router';

import { userService } from 'services';

export { Layout };

function Layout({ children }) {
    const router = useRouter();

    useEffect(() => {
        // redirect to home if already logged in
        if (userService.userValue) {
            router.push('/');
        }
    }, []);

    return (
        <div className="col-md-6 offset-md-3 mt-5">
            {children}
        </div>
    );
}
 

Users Add/Edit Component

Path: /components/users/AddEdit.jsx

The users AddEdit component is used for both adding and editing users, it contains a form built with the React Hook Form library and is used by the add user page and edit user page.

Form validation rules are defined with the Yup schema validation library and passed with the formOptions to the React Hook Form useForm() function, for more info on Yup see https://github.com/jquense/yup.

The useForm() hook function returns an object with methods for working with a form including registering inputs, handling form submit, resetting the form, accessing form state, displaying errors and more, for a complete list see https://react-hook-form.com/api/useform.

The onSubmit function gets called when the form is submitted and valid, and either creates or updates a user depending on which mode it is in.

The form is in edit mode when there is no user passed in the component props (props.user), otherwise it is in add mode. In edit mode the user details are preloaded into the form fields with the React Hook Form formOptions.defaultValues. Also the password field is required in add mode and optional in edit mode.

The returned JSX template contains the form with all of the input fields and validation messages. The form fields are registered with the React Hook Form by calling the register function with the field name from each input element (e.g. {...register('firstName')}). For more info on form validation with React Hook Form see React Hook Form 7 - Form Validation Example.

import { useRouter } from 'next/router';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';

import { userService, alertService } from 'services';

export { AddEdit };

function AddEdit(props) {
    const user = props?.user;
    const router = useRouter();

    // form validation rules 
    const validationSchema = Yup.object().shape({
        firstName: Yup.string()
            .required('First Name is required'),
        lastName: Yup.string()
            .required('Last Name is required'),
        username: Yup.string()
            .required('Username is required'),
        password: Yup.string()
            .transform(x => x === '' ? undefined : x)
            // password optional in edit mode
            .concat(user ? null : Yup.string().required('Password is required'))
            .min(6, 'Password must be at least 6 characters')
    });
    const formOptions = { resolver: yupResolver(validationSchema) };

    // set default form values if in edit mode
    if (user) {
        formOptions.defaultValues = props.user;
    }

    // get functions to build form with useForm() hook
    const { register, handleSubmit, reset, formState } = useForm(formOptions);
    const { errors } = formState;

    async function onSubmit(data) {
        alertService.clear();
        try {
            // create or update user based on user prop
            let message;
            if (user) {
                await userService.update(user.id, data);
                message = 'User updated';
            } else {
                await userService.register(data);
                message = 'User added';
            }

            // redirect to user list with success message
            router.push('/users');
            alertService.success(message, true);
        } catch (error) {
            alertService.error(error);
            console.error(error);
        }
    }

    return (
        <form onSubmit={handleSubmit(onSubmit)}>
            <div className="row">
                <div className="mb-3 col">
                    <label className="form-label">First Name</label>
                    <input name="firstName" type="text" {...register('firstName')} className={`form-control ${errors.firstName ? 'is-invalid' : ''}`} />
                    <div className="invalid-feedback">{errors.firstName?.message}</div>
                </div>
                <div className="mb-3 col">
                    <label className="form-label">Last Name</label>
                    <input name="lastName" type="text" {...register('lastName')} className={`form-control ${errors.lastName ? 'is-invalid' : ''}`} />
                    <div className="invalid-feedback">{errors.lastName?.message}</div>
                </div>
            </div>
            <div className="row">
                <div className="mb-3 col">
                    <label className="form-label">Username</label>
                    <input name="username" type="text" {...register('username')} className={`form-control ${errors.username ? 'is-invalid' : ''}`} />
                    <div className="invalid-feedback">{errors.email?.message}</div>
                </div>
                <div className="mb-3 col">
                    <label className="form-label">
                        Password
                        {user && <em className="ms-1">(Leave blank to keep the same password)</em>}
                    </label>
                    <input name="password" type="password" {...register('password')} className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
                    <div className="invalid-feedback">{errors.password?.message}</div>
                </div>
            </div>
            <div className="mb-3">
                <button type="submit" disabled={formState.isSubmitting} className="btn btn-primary me-2">
                    {formState.isSubmitting && <span className="spinner-border spinner-border-sm me-1"></span>}
                    Save
                </button>
                <button onClick={() => reset(formOptions.defaultValues)} type="button" disabled={formState.isSubmitting} className="btn btn-secondary">Reset</button>
                <Link href="/users" className="btn btn-link">Cancel</Link>
            </div>
        </form>
    );
}
 

Users Layout Component

Path: /components/users/Layout.jsx

The users layout component contains common layout code for all pages in the /pages/users folder, it simply wraps the {children} elements in a couple of div tags with some bootstrap classes to set the width, padding and alignment of all of the users pages. The Layout component is imported by each users page and used to wrap the returned JSX template (e.g. users index page).

export { Layout };

function Layout({ children }) {
    return (
        <div className="p-4">
            <div className="container">
                {children}
            </div>
        </div>
    );
}
 

Alert Component

Path: /components/Alert.jsx

The alert component renders the alert from the alert service with bootstrap CSS classes, if the service doesn't contain an alert nothing is rendered by the component.

The first useEffect() hook is used to subscribe to the observable alertService.alert, this enables the alert component to be notified whenever an alert message is sent to the alert service.

The component automatically clears the alert on location change with the second useEffect() hook that has a dependency on the router object.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';

import { alertService } from 'services';

export { Alert };

function Alert() {
    const router = useRouter();
    const [alert, setAlert] = useState(null);

    useEffect(() => {
        // subscribe to new alert notifications
        const subscription = alertService.alert.subscribe(alert => setAlert(alert));

        // unsubscribe when the component unmounts
        return () => subscription.unsubscribe();
    }, []);

    useEffect(() => {
        // clear alert on location change
        alertService.clear();
    }, [router]);

    if (!alert) return null;

    return (
        <div className="container">
            <div className="m-3">
                <div className={`alert alert-dismissible ${alert.type}`}>
                    {alert.message}
                    <button type="button" className="btn-close" onClick={() => alertService.clear()}></button>
                </div>
            </div>
        </div>
    );
}
 

Nav Component

Path: /components/Nav.jsx

The nav component displays the main navigation in the example. The custom NavLink component automatically adds the active class to the active nav item so it is highlighted in the UI.

import { useState, useEffect } from 'react';

import { NavLink } from '.';
import { userService } from 'services';

export { Nav };

function Nav() {
    const [user, setUser] = useState(null);

    useEffect(() => {
        const subscription = userService.user.subscribe(x => setUser(x));
        return () => subscription.unsubscribe();
    }, []);

    // only show nav when logged in
    if (!user) return null;

    return (
        <nav className="navbar navbar-expand navbar-dark bg-dark px-3">
            <div className="navbar-nav">
                <NavLink href="/" exact className="nav-item nav-link">Home</NavLink>
                <NavLink href="/users" className="nav-item nav-link">Users</NavLink>
                <button onClick={userService.logout} className="btn btn-link nav-item nav-link">Logout</button>
            </div>
        </nav>
    );
}
 

NavLink Component

Path: /components/NavLink.jsx

An extended version of the Next.js Link component that adds the CSS className "active" when the href matches the current URL. By default the href only needs to match the start of the URL, use the exact property to change it to an exact match (e.g. <NavLink href="/" exact>Home</NavLink>).

import { useRouter } from 'next/router';
import Link from 'next/link';
import PropTypes from 'prop-types';

export { NavLink };

NavLink.propTypes = {
    href: PropTypes.string.isRequired,
    exact: PropTypes.bool
};

NavLink.defaultProps = {
    exact: false
};

function NavLink({ children, href, exact, ...props }) {
    const { pathname } = useRouter();
    const isActive = exact ? pathname === href : pathname.startsWith(href);

    if (isActive) {
        props.className += ' active';
    }

    return <Link href={href} {...props}>{children}</Link>;
}
 

Spinner Component

Path: /components/Spinner.jsx

A simple bootstrap loading spinner component, used by the users index page and edit user page.

export { Spinner };

function Spinner() {
    return (
        <div className="text-center p-4">
            <span className="spinner-border spinner-border-lg align-center"></span>
        </div>
    );
}
 

Next.js API Handler

Path: /helpers/api/api-handler.js

The API handler is a wrapper function for all API route handlers in the /pages/api folder (e.g. authenticate handler, register handler).

It enables adding global middleware to the Next.js request pipeline and adds support for global exception handling. The wrapper function accepts a handler object that contains a method for each HTTP method that is supported by the handler (e.g. get, post, put, delete etc). If a request is received for an unsupported HTTP method a 405 Method Not Allowed response is returned.

import { errorHandler, jwtMiddleware } from 'helpers/api';

export { apiHandler };

function apiHandler(handler) {
    return async (req, res) => {
        const method = req.method.toLowerCase();

        // check handler supports HTTP method
        if (!handler[method])
            return res.status(405).end(`Method ${req.method} Not Allowed`);

        try {
            // global middleware
            await jwtMiddleware(req, res);

            // route handler
            await handler[method](req, res);
        } catch (err) {
            // global error handler
            errorHandler(err, res);
        }
    }
}
 

MongoDB Data Context

Path: /helpers/api/db.js

The MongoDB data context is used to connect to MongoDB using Mongoose and exports an object containing all of the database model objects in the application (currently only User). It provides an easy way to access any part of the database from a single point.

import getConfig from 'next/config';
import mongoose from 'mongoose';

const { serverRuntimeConfig } = getConfig();
const Schema = mongoose.Schema;

mongoose.connect(process.env.MONGODB_URI || serverRuntimeConfig.connectionString);
mongoose.Promise = global.Promise;

export const db = {
    User: userModel()
};

// mongoose models with schema definitions

function userModel() {
    const schema = new Schema({
        username: { type: String, unique: true, required: true },
        hash: { type: String, required: true },
        firstName: { type: String, required: true },
        lastName: { type: String, required: true }
    }, {
        // add createdAt and updatedAt timestamps
        timestamps: true
    });

    schema.set('toJSON', {
        virtuals: true,
        versionKey: false,
        transform: function (doc, ret) {
            delete ret._id;
            delete ret.hash;
        }
    });

    return mongoose.models.User || mongoose.model('User', schema);
}
 

Next.js Global Error Handler

Path: /helpers/api/error-handler.js

The global error handler is used catch all errors and remove the need for duplicated error handling code throughout the Next.js tutorial api. It's added to the request pipeline in the API handler wrapper function.

By convention errors of type 'string' are treated as custom (app specific) errors, this simplifies the code for throwing custom errors since only a string needs to be thrown (e.g. throw 'Username or password is incorrect'), if a custom error ends with the words 'not found' a 404 response code is returned, otherwise a standard 400 error response is returned.

If the error is an object with the name 'UnauthorizedError' it means JWT token validation has failed so a HTTP 401 unauthorized response code is returned with the message 'Invalid Token'.

All other (unhandled) exceptions are logged to the console and return a 500 server error response code.

export { errorHandler };

function errorHandler(err, res) {
    if (typeof (err) === 'string') {
        // custom application error
        const is404 = err.toLowerCase().endsWith('not found');
        const statusCode = is404 ? 404 : 400;
        return res.status(statusCode).json({ message: err });
    }

    if (err.name === 'UnauthorizedError') {
        // jwt authentication error
        return res.status(401).json({ message: 'Invalid Token' });
    }

    // default to 500 server error
    console.error(err);
    return res.status(500).json({ message: err.message });
}
 

Next.js JWT Middleware

Path: /helpers/api/jwt-middleware.js

The JWT middleware uses the express-jwt library to validate JWT tokens in requests sent to protected API routes, if the token is invalid an error is thrown which causes the global error handler to return a 401 Unauthorized response. The JWT middleware is added to the Next.js request pipeline in the API handler wrapper function.

The register and authenticate routes are made public by passing them to the unless() method of the express-jwt library. For more info on express-jwt see https://www.npmjs.com/package/express-jwt.

import { expressjwt } from 'express-jwt';
import util from 'util';
import getConfig from 'next/config';

const { serverRuntimeConfig } = getConfig();

export { jwtMiddleware };

function jwtMiddleware(req, res) {
    const middleware = expressjwt({ secret: serverRuntimeConfig.secret, algorithms: ['HS256'] }).unless({
        path: [
            // public routes that don't require authentication
            '/api/users/register',
            '/api/users/authenticate'
        ]
    });

    return util.promisify(middleware)(req, res);
}
 

MongoDB Users Repo

Path: /helpers/users-repo.js

The users repo encapsulates all access to user data stored in MongoDB, it exposes methods for authentication and standard CRUD operations for reading and managing user data. The repo is used on the server-side by the Next.js users API route handlers (authenticate.js, register.js, [id].js, index.js).

On successful authentication a JWT (JSON Web Token) is generated with the jsonwebtoken npm package, the token is digitally signed using the secret key stored in next.config.js so it can't be tampered with. The JWT token is returned to the client application which must include it in the HTTP Authorization header of subsequent requests to secure routes, this is handled by the fetch wrapper in the tutorial app.

Bcrypt is used to hash and verify passwords in the Next.js tutorial with the bcryptjs library, for more info see Node.js - Hash and Verify Passwords with Bcrypt.

import getConfig from 'next/config';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { db } from 'helpers/api';

const { serverRuntimeConfig } = getConfig();
const User = db.User;

export const usersRepo = {
    authenticate,
    getAll,
    getById,
    create,
    update,
    delete: _delete
};

async function authenticate({ username, password }) {
    const user = await User.findOne({ username });

    if (!(user && bcrypt.compareSync(password, user.hash))) {
        throw 'Username or password is incorrect';
    }

    // create a jwt token that is valid for 7 days
    const token = jwt.sign({ sub: user.id }, serverRuntimeConfig.secret, { expiresIn: '7d' });

    return {
        ...user.toJSON(),
        token
    };
}

async function getAll() {
    return await User.find();
}

async function getById(id) {
    return await User.findById(id);
}

async function create(params) {
    // validate
    if (await User.findOne({ username: params.username })) {
        throw 'Username "' + params.username + '" is already taken';
    }

    const user = new User(params);

    // hash password
    if (params.password) {
        user.hash = bcrypt.hashSync(params.password, 10);
    }

    // save user
    await user.save();
}

async function update(id, params) {
    const user = await User.findById(id);

    // validate
    if (!user) throw 'User not found';
    if (user.username !== params.username && await User.findOne({ username: params.username })) {
        throw 'Username "' + params.username + '" is already taken';
    }

    // hash password if it was entered
    if (params.password) {
        params.hash = bcrypt.hashSync(params.password, 10);
    }

    // copy params properties to user
    Object.assign(user, params);

    await user.save();
}

async function _delete(id) {
    await User.findByIdAndRemove(id);
}
 

JavaScript Fetch Wrapper

Path: /helpers/fetch-wrapper.js

The fetch wrapper is a lightweight wrapper around the native browser fetch() function used to simplify the code for making HTTP requests. It contains methods for get, post, put and delete requests, it automatically handles the parsing of JSON data from responses, and throws an error if the HTTP response is not successful (!response.ok). If the response is 401 Unauthorized or 403 Forbidden the user is automatically logged out of the Next.js app.

The authHeader() function is used to automatically add a JWT auth token to the HTTP Authorization header of the request if the user is logged in and the request is to the application API URL.

With the fetch wrapper a POST request can be made as simply as this: fetchWrapper.post(url, body);. It's used in the example app by the user service. For more info see Fetch API - A Lightweight Fetch Wrapper to Simplify HTTP Requests.

import getConfig from 'next/config';

import { userService } from 'services';

const { publicRuntimeConfig } = getConfig();

export const fetchWrapper = {
    get: request('GET'),
    post: request('POST'),
    put: request('PUT'),
    delete: request('DELETE')
};

function request(method) {
    return (url, body) => {
        const requestOptions = {
            method,
            headers: authHeader(url)
        };
        if (body) {
            requestOptions.headers['Content-Type'] = 'application/json';
            requestOptions.body = JSON.stringify(body);
        }
        return fetch(url, requestOptions).then(handleResponse);
    }
}

// helper functions

function authHeader(url) {
    // return auth header with jwt if user is logged in and request is to the api url
    const user = userService.userValue;
    const isLoggedIn = user?.token;
    const isApiUrl = url.startsWith(publicRuntimeConfig.apiUrl);
    if (isLoggedIn && isApiUrl) {
        return { Authorization: `Bearer ${user.token}` };
    } else {
        return {};
    }
}

async function handleResponse(response) {
    const isJson = response.headers?.get('content-type')?.includes('application/json');
    const data = isJson ? await response.json() : null;

    // check for error response
    if (!response.ok) {
        if ([401, 403].includes(response.status) && userService.userValue) {
            // auto logout if 401 Unauthorized or 403 Forbidden response returned from api
            userService.logout();
        }

        // get error message from body or default to response status
        const error = (data && data.message) || response.statusText;
        return Promise.reject(error);
    }

    return data;
}
 

Next.js Login Form

Path: /pages/account/login.jsx

The login page contains a form built with the React Hook Form library that contains username and password fields for logging into the Next.js tutorial app.

Form validation rules are defined with the Yup schema validation library and passed with the formOptions to the React Hook Form useForm() function, for more info on Yup see https://github.com/jquense/yup.

The useForm() hook function returns an object with methods for working with a form including registering inputs, handling form submit, accessing form state, displaying errors and more, for a complete list see https://react-hook-form.com/api/useform.

The onSubmit function gets called when the form is submitted and valid, and submits the user credentials to the api by calling userService.login().

The returned JSX template contains the markup for page including the form, input fields and validation messages. The form fields are registered with the React Hook Form by calling the register function with the field name from each input element (e.g. {...register('username')}). For more info on form validation with React Hook Form see React Hook Form 7 - Form Validation Example.

import { useRouter } from 'next/router';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';

import { Layout } from 'components/account';
import { userService, alertService } from 'services';

export default Login;

function Login() {
    const router = useRouter();

    // form validation rules 
    const validationSchema = Yup.object().shape({
        username: Yup.string().required('Username is required'),
        password: Yup.string().required('Password is required')
    });
    const formOptions = { resolver: yupResolver(validationSchema) };

    // get functions to build form with useForm() hook
    const { register, handleSubmit, formState } = useForm(formOptions);
    const { errors } = formState;

    function onSubmit({ username, password }) {
        alertService.clear();
        return userService.login(username, password)
            .then(() => {
                // get return url from query parameters or default to '/'
                const returnUrl = router.query.returnUrl || '/';
                router.push(returnUrl);
            })
            .catch(alertService.error);
    }

    return (
        <Layout>
            <div className="card">
                <h4 className="card-header">Login</h4>
                <div className="card-body">
                    <form onSubmit={handleSubmit(onSubmit)}>
                        <div className="mb-3">
                            <label className="form-label">Username</label>
                            <input name="username" type="text" {...register('username')} className={`form-control ${errors.username ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.username?.message}</div>
                        </div>
                        <div className="mb-3">
                            <label className="form-label">Password</label>
                            <input name="password" type="password" {...register('password')} className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.password?.message}</div>
                        </div>
                        <button disabled={formState.isSubmitting} className="btn btn-primary">
                            {formState.isSubmitting && <span className="spinner-border spinner-border-sm me-1"></span>}
                            Login
                        </button>
                        <Link href="/account/register" className="btn btn-link">Register</Link>
                    </form>
                </div>
            </div>
        </Layout>
    );
}
 

Next.js Registration Form

Path: /pages/account/register.jsx

The register page contains a simple registration form built with the React Hook Form library with fields for first name, last name, username and password.

Form validation rules are defined with the Yup schema validation library and passed with the formOptions to the React Hook Form useForm() function, for more info on Yup see https://github.com/jquense/yup.

The useForm() hook function returns an object with methods for working with a form including registering inputs, handling form submit, accessing form state, displaying errors and more, for a complete list see https://react-hook-form.com/api/useform.

The onSubmit function gets called when the form is submitted and valid, and submits the form data to the Next.js api by calling userService.register().

The returned JSX template contains the markup for page including the form, input fields and validation messages. The form fields are registered with the React Hook Form by calling the register function with the field name from each input element (e.g. {...register('username')}). For more info on form validation with React Hook Form see React Hook Form 7 - Form Validation Example.

import { useRouter } from 'next/router';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';

import { Layout } from 'components/account';
import { userService, alertService } from 'services';

export default Register;

function Register() {
    const router = useRouter();

    // form validation rules 
    const validationSchema = Yup.object().shape({
        firstName: Yup.string()
            .required('First Name is required'),
        lastName: Yup.string()
            .required('Last Name is required'),
        username: Yup.string()
            .required('Username is required'),
        password: Yup.string()
            .required('Password is required')
            .min(6, 'Password must be at least 6 characters')
    });
    const formOptions = { resolver: yupResolver(validationSchema) };

    // get functions to build form with useForm() hook
    const { register, handleSubmit, formState } = useForm(formOptions);
    const { errors } = formState;

    function onSubmit(user) {
        return userService.register(user)
            .then(() => {
                alertService.success('Registration successful', true);
                router.push('login');
            })
            .catch(alertService.error);
    }

    return (
        <Layout>
            <div className="card">
                <h4 className="card-header">Register</h4>
                <div className="card-body">
                    <form onSubmit={handleSubmit(onSubmit)}>
                        <div className="mb-3">
                            <label className="form-label">First Name</label>
                            <input name="firstName" type="text" {...register('firstName')} className={`form-control ${errors.firstName ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.firstName?.message}</div>
                        </div>
                        <div className="mb-3">
                            <label className="form-label">Last Name</label>
                            <input name="lastName" type="text" {...register('lastName')} className={`form-control ${errors.lastName ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.lastName?.message}</div>
                        </div>
                        <div className="mb-3">
                            <label className="form-label">Username</label>
                            <input name="username" type="text" {...register('username')} className={`form-control ${errors.username ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.username?.message}</div>
                        </div>
                        <div className="mb-3">
                            <label className="form-label">Password</label>
                            <input name="password" type="password" {...register('password')} className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.password?.message}</div>
                        </div>
                        <button disabled={formState.isSubmitting} className="btn btn-primary">
                            {formState.isSubmitting && <span className="spinner-border spinner-border-sm me-1"></span>}
                            Register
                        </button>
                        <Link href="/account/login" className="btn btn-link">Cancel</Link>
                    </form>
                </div>
            </div>
        </Layout>
    );
}
 

Users [id] API Route Handler

Path: /pages/api/users/[id].js

A dynamic API route handler that handles HTTP requests with any value as the [id] parameter (i.e. /api/users/*). The user id parameter is attached by Next.js to the req.query object which is accessible to the route handler.

The route handler supports HTTP GET, PUT and DELETE requests by passing an object with those method names (in lower case) to the apiHandler() which maps them to the functions getById(), update() and _delete().

import { apiHandler, usersRepo } from 'helpers/api';

export default apiHandler({
    get: getById,
    put: update,
    delete: _delete
});

async function getById(req, res) {
    const user = await usersRepo.getById(req.query.id);

    if (!user) throw 'User Not Found';

    return res.status(200).json(user);
}

async function update(req, res) {
    await usersRepo.update(req.query.id, req.body);
    return res.status(200).json({});
}

async function _delete(req, res) {
    await usersRepo.delete(req.query.id);
    return res.status(200).json({});
}
 

Users Authenticate API Route Handler

Path: /pages/api/users/authenticate.js

The authenticate handler receives HTTP requests sent to the authenticate route /api/users/authenticate. It supports HTTP POST requests containing a username and password which are authenticated by the authenticate() function.

The route handler supports HTTP POST requests by passing an object with a post() method to the apiHandler() which is mapped to the authenticate() function.

import { apiHandler, usersRepo } from 'helpers/api';

export default apiHandler({
    post: authenticate
});

async function authenticate(req, res) {
    const user = await usersRepo.authenticate(req.body);
    return res.status(200).json(user);
}
 

Users Index API Route Handler

Path: /pages/api/users/index.js

The users index handler receives HTTP requests sent to the base users route /api/users. It supports HTTP GET requests by passing an object with a get() method to the apiHandler() which is mapped to the getAll() function. The getAll() function returns all users from MongoDB by calling usersRepo.getAll().

Security for this route (and all other secure routes in the Next.js API) is handled by the global JWT middleware.

import { apiHandler, usersRepo } from 'helpers/api';

export default apiHandler({
    get: getAll
});

async function getAll(req, res) {
    const users = await usersRepo.getAll();
    return res.status(200).json(users);
}
 

Users Register API Route Handler

Path: /pages/api/users/register.js

The register handler receives HTTP requests sent to the register route /api/users/register. It supports HTTP POST requests containing user details which are registered in the Next.js tutorial app by the register() function. On successful registration a 200 OK response is returned with an empty JSON object.

The route handler supports HTTP POST requests by passing an object with a post() method to the apiHandler() function.

import { apiHandler, usersRepo } from 'helpers/api';

export default apiHandler({
    post: register
});

async function register(req, res) {
    await usersRepo.create(req.body);
    return res.status(200).json({});
}
 

Edit User Page

Path: /pages/users/edit/[id].jsx

The edit user page wraps the add/edit user component and passes it the specified user to set the component to "edit" mode. The user is fetched from the API in a useEffect() React hook with the id parameter from the URL, the id is accessible via the Next.js router property router.query.id.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';

import { Layout, AddEdit } from 'components/users';
import { Spinner } from 'components';
import { userService, alertService } from 'services';

export default Edit;

function Edit() {
    const router = useRouter();
    const [user, setUser] = useState(null);

    useEffect(() => {
        const { id } = router.query;
        if (!id) return;

        // fetch user and set default form values if in edit mode
        userService.getById(id)
            .then(x => setUser(x))
            .catch(alertService.error)
    }, [router]);

    return (
        <Layout>
            <h1>Edit User</h1>
            {user ? <AddEdit user={user} /> : <Spinner />}
        </Layout>
    );
}
 

Add User Page

Path: /pages/users/add.jsx

The add user page is a thin wrapper around the add/edit user component without any user specified so the component is set to "add" mode.

import { Layout, AddEdit } from 'components/users';

export default Add;

function Add() {
    return (
        <Layout>
            <h1>Add User</h1>
            <AddEdit />
        </Layout>
    );
}
 

Users Index Page

Path: /pages/users/index.jsx

The users index page displays a list of all users in the Next.js tutorial app and contains buttons for adding, editing and deleting users. A useEffect hook is used to get all users from the user service and store them in local state by calling setUsers().

The delete button calls the deleteUser() function which first updates the user is local state with an isDeleting = true property so the UI displays a spinner on the delete button, it then calls userService.delete() to delete the user from the Next.js API, then removes the deleted user from local state to remove it from the UI.

import Link from 'next/link';
import { useState, useEffect } from 'react';

import { Spinner } from 'components';
import { Layout } from 'components/users';
import { userService } from 'services';

export default Index;

function Index() {
    const [users, setUsers] = useState(null);

    useEffect(() => {
        userService.getAll().then(x => setUsers(x));
    }, []);

    function deleteUser(id) {
        setUsers(users.map(x => {
            if (x.id === id) { x.isDeleting = true; }
            return x;
        }));
        userService.delete(id).then(() => {
            setUsers(users => users.filter(x => x.id !== id));
        });
    }

    return (
        <Layout>
            <h1>Users</h1>
            <Link href="/users/add" className="btn btn-sm btn-success mb-2">Add User</Link>
            <table className="table table-striped">
                <thead>
                    <tr>
                        <th style={{ width: '30%' }}>First Name</th>
                        <th style={{ width: '30%' }}>Last Name</th>
                        <th style={{ width: '30%' }}>Username</th>
                        <th style={{ width: '10%' }}></th>
                    </tr>
                </thead>
                <tbody>
                    {users && users.map(user =>
                        <tr key={user.id}>
                            <td>{user.firstName}</td>
                            <td>{user.lastName}</td>
                            <td>{user.username}</td>
                            <td style={{ whiteSpace: 'nowrap' }}>
                                <Link href={`/users/edit/${user.id}`} className="btn btn-sm btn-primary me-1">Edit</Link>
                                <button onClick={() => deleteUser(user.id)} className="btn btn-sm btn-danger btn-delete-user" style={{ width: '60px' }} disabled={user.isDeleting}>
                                    {user.isDeleting
                                        ? <span className="spinner-border spinner-border-sm"></span>
                                        : <span>Delete</span>
                                    }
                                </button>
                            </td>
                        </tr>
                    )}
                    {!users &&
                        <tr>
                            <td colSpan="4">
                                <Spinner />
                            </td>
                        </tr>
                    }
                    {users && !users.length &&
                        <tr>
                            <td colSpan="4" className="text-center">
                                <div className="p-2">No Users To Display</div>
                            </td>
                        </tr>
                    }
                </tbody>
            </table>
        </Layout>
    );
}
 

Next.js App Component

Path: /pages/_app.js

The App component is the root React component of the example Next.js app, it contains the outer html, main nav, global alert, and the component for the current page.

Client-side authorization is implemented in the authCheck() function which is executed on initial app load and on each route change. If you try to access a secure page (e.g. the home page /) without logging in, the page contents won't be displayed and you'll be redirected to the /login page. The authorized state property is used to prevent the brief display of secure pages before the redirect because I couldn't find a clean way to cancel a route change using the Next.js routeChangeStart event and then redirecting to a new page.

The Next.js Head component is used to set the default <title> in the html <head> element for all pages. For more info on the Next.js head component see https://nextjs.org/docs/api-reference/next/head.

The App component overrides the default Next.js App component because it's in a file named /pages/_app.js and supports several features, for more info see https://nextjs.org/docs/advanced-features/custom-app.

import Head from 'next/head';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';

import 'styles/globals.css';

import { userService } from 'services';
import { Nav, Alert } from 'components';

export default App;

function App({ Component, pageProps }) {
    const router = useRouter();
    const [user, setUser] = useState(null);
    const [authorized, setAuthorized] = useState(false);

    useEffect(() => {
        // on initial load - run auth check 
        authCheck(router.asPath);

        // on route change start - hide page content by setting authorized to false  
        const hideContent = () => setAuthorized(false);
        router.events.on('routeChangeStart', hideContent);

        // on route change complete - run auth check 
        router.events.on('routeChangeComplete', authCheck)

        // unsubscribe from events in useEffect return function
        return () => {
            router.events.off('routeChangeStart', hideContent);
            router.events.off('routeChangeComplete', authCheck);
        }
    }, []);

    function authCheck(url) {
        // redirect to login page if accessing a private page and not logged in 
        setUser(userService.userValue);
        const publicPaths = ['/account/login', '/account/register'];
        const path = url.split('?')[0];
        if (!userService.userValue && !publicPaths.includes(path)) {
            setAuthorized(false);
            router.push({
                pathname: '/account/login',
                query: { returnUrl: router.asPath }
            });
        } else {
            setAuthorized(true);
        }
    }

    return (
        <>
            <Head>
                <title>Next.js 13 - User Registration and Login Example</title>
            </Head>

            <div className={`app-container ${user ? 'bg-light' : ''}`}>
                <Nav />
                <Alert />
                {authorized &&
                    <Component {...pageProps} />
                }
            </div>
        </>
    );
}
 

Next.js Document Component

Path: /pages/_document.js

The custom Document component contains the base html page that loads the React client side app, it's kind of like the main index page (/public/index.html) in a pure React app.

A custom document is only required if you want to update the default <html>, <head> or <body> tags for all pages. I added it to the example to include the Bootstrap CSS stylesheet tag in the <head> and {/* credits */} links at the bottom of the <body>.

The Document component overrides the default Next.js Document component because it's in a file named /pages/_document.js and supports several features, for more info see https://nextjs.org/docs/advanced-features/custom-document.

import { Html, Head, Main, NextScript } from 'next/document'

export default Document;

function Document() {
    return (
        <Html lang="en">
            <Head>
                {/* eslint-disable-next-line @next/next/no-css-tags */}
                <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" />
            </Head>

            <body>
                <Main />
                <NextScript />

                {/* credits */}
                <div className="text-center mt-4">
                    <p>
                        <a href="https://jasonwatmore.com/next-js-13-mongodb-user-registration-and-login-tutorial-with-example-app" target="_top">Next.js 13 + MongoDB - User Registration and Login Tutorial with Example App</a>
                    </p>
                    <p>
                        <a href="https://jasonwatmore.com" target="_top">JasonWatmore.com</a>
                    </p>
                </div>
            </body>
        </Html>
    );
}
 

Home Page

Path: /pages/index.jsx

The home page is a simple React component that displays a welcome message to the logged in user and a link to the users section.

import Link from 'next/link';

import { userService } from 'services';

export default Home;

function Home() {
    return (
        <div className="p-4">
            <div className="container">
                <h1>Hi {userService.userValue?.firstName}!</h1>
                <p>You're logged in with Next.js & JWT!!</p>
                <p><Link href="/users">Manage Users</Link></p>
            </div>
        </div>
    );
}
 

Alert Service

Path: /services/alert.service.js

The alert service acts as the bridge between any component in the Next.js tutorial app and the alert component that displays notifications. It contains methods for sending and clearing alerts, and an observable property for subscribing to alerts.

You can trigger an alert notification from any component in the application by calling one of the methods for displaying different types of alerts (success() and error()).

Alert method parameters

  • The first parameter is the alert message string.
  • The second parameter is an optional showAfterRedirect boolean parameter that keeps the alert displayed after one route change, e.g. after a redirect when a user is created or updated. Default value is false.

For a more detailed alerts example see Next.js - Alert (Toaster) Notifications.

import { BehaviorSubject } from 'rxjs';

const alertSubject = new BehaviorSubject(null);

export const alertService = {
    alert: alertSubject.asObservable(),
    success,
    error,
    clear
};

function success(message, showAfterRedirect = false) {
    alertSubject.next({
        type: 'alert-success',
        message,
        showAfterRedirect
    });
}

function error(message, showAfterRedirect = false) {
    alertSubject.next({
        type: 'alert-danger',
        message,
        showAfterRedirect
    });
}

// clear alerts
function clear() {
    // if showAfterRedirect flag is true the alert is not cleared 
    // for one route change (e.g. after successful registration)
    let alert = alertSubject.value;
    if (alert?.showAfterRedirect) {
        alert.showAfterRedirect = false;
    } else {
        alert = null;
    }
    alertSubject.next(alert);
}
 

User Service

Path: /services/user.service.js

The user service handles communication between the React front-end of the Next.js tutorial app and the backend API for everything related to users. It contains methods for logging in and out of the app, registering a new user, and standard CRUD methods for retrieving and updating user data. HTTP requests are sent with the help of the fetch wrapper.

On successful login the returned user is stored in browser local storage to keep the user logged in between page refreshes and browser sessions, if you prefer not to use local storage you can simply remove it from the user service and the application will continue to work correctly, except for staying logged in between page refreshes.

The user property exposes an RxJS Observable so any component can subscribe to be notified when a user logs in, logs out or updates their profile. The notification is triggered by the call to userSubject.next() from each of those methods. For more info on component communication with RxJS see the tutorial React Hooks + RxJS - Communicating Between Components with Observable & Subject.

The userValue getter allows other components to easily get the current value of the logged in user without having to subscribe to the user observable.

import { BehaviorSubject } from 'rxjs';
import getConfig from 'next/config';
import Router from 'next/router';

import { fetchWrapper } from 'helpers';
import { alertService } from './alert.service';

const { publicRuntimeConfig } = getConfig();
const baseUrl = `${publicRuntimeConfig.apiUrl}/users`;
const userSubject = new BehaviorSubject(typeof window !== 'undefined' && JSON.parse(localStorage.getItem('user')));

export const userService = {
    user: userSubject.asObservable(),
    get userValue() { return userSubject.value },
    login,
    logout,
    register,
    getAll,
    getById,
    update,
    delete: _delete
};

async function login(username, password) {
    const user = await fetchWrapper.post(`${baseUrl}/authenticate`, { username, password });

    // publish user to subscribers and store in local storage to stay logged in between page refreshes
    userSubject.next(user);
    localStorage.setItem('user', JSON.stringify(user));
}

function logout() {
    alertService.clear();
    // remove user from local storage, publish null to user subscribers and redirect to login page
    localStorage.removeItem('user');
    userSubject.next(null);
    Router.push('/account/login');
}

async function register(user) {
    await fetchWrapper.post(`${baseUrl}/register`, user);
}

async function getAll() {
    return await fetchWrapper.get(baseUrl);
}

async function getById(id) {
    return await fetchWrapper.get(`${baseUrl}/${id}`);
}

async function update(id, params) {
    await fetchWrapper.put(`${baseUrl}/${id}`, params);

    // update stored user if the logged in user updated their own record
    if (id === userSubject.value.id) {
        // update local storage
        const user = { ...userSubject.value, ...params };
        localStorage.setItem('user', JSON.stringify(user));

        // publish updated user to subscribers
        userSubject.next(user);
    }
}

// prefixed with underscored because delete is a reserved word in javascript
async function _delete(id) {
    await fetchWrapper.delete(`${baseUrl}/${id}`);

    // auto logout if the logged in user deleted their own record
    if (id === userSubject.value.id) {
        logout();
    }
}
 

Global CSS Styles

Path: /styles/globals.css

The globals.css file contains global custom CSS styles for the Next.js example auth app. It's imported into the tutorial app by the Next.js app component.

.app-container {
    min-height: 350px;
}
 

ESLint Config

Path: /.eslintrc.json

The ESLint RC file configures which rules are enabled when npm run lint or next lint is run on the project.

The default rule set is next/core-web-vitals, this is included when a Next.js app is created with the command npx create-next-app@latest. For more info on create-next-app see https://nextjs.org/docs/api-reference/create-next-app.

Disabling "react-hooks/exhaustive-deps" rule

I disabled the react-hooks/exhaustive-deps rule to prevent warnings on useEffect() hooks that intentionally have an empty dependency array in order to run once on component load.

{
    "extends": "next/core-web-vitals",
    "rules": {
        "react-hooks/exhaustive-deps": "off"
    }
}
 

JavaScript Config

Path: /jsconfig.json

The jsconfig baseUrl option is used to configure absolute imports for the Next.js tutorial app. Setting the base url to "." makes all javascript import statements (without a dot '.' prefix) relative to the root folder of the project, removing the need for long relative paths like import { userService } from '../../../services';.

Next.js supports absolute imports and module path aliases in the jsconfig file, for more info see https://nextjs.org/docs/advanced-features/module-path-aliases.

{
    "compilerOptions": {
        // make all imports without a dot '.' prefix relative to the base url
        "baseUrl": "."
    }
}
 

Next.js Config

Path: /next.config.js

The Next.js config file defines global config variables that are available to components in the Next.js tutorial app. It supports setting different values for variables based on environment (e.g. development vs production).

serverRuntimeConfig variables are only available to the API on the server side, while publicRuntimeConfig variables are available to the API and the client React app.

The apiUrl is used by the user service to send HTTP requests to the API.

IMPORTANT: The secret property is used to sign and verify JWT tokens for authentication, change it with your own random string to ensure nobody else can generate a JWT with the same secret to gain unauthorized access to your api. A quick and easy way is join a couple of GUIDs together to make a long random string (e.g. from https://www.guidgenerator.com/).

/** @type {import('next').NextConfig} */
const nextConfig = {
    reactStrictMode: true,
    serverRuntimeConfig: {
        connectionString: "mongodb://localhost/next-js-registration-login-example",
        secret: 'THIS IS USED TO SIGN AND VERIFY JWT TOKENS, REPLACE IT WITH YOUR OWN SECRET, IT CAN BE ANY STRING'
    },
    publicRuntimeConfig: {
        apiUrl: process.env.NODE_ENV === 'development'
            ? 'http://localhost:3000/api' // development api
            : 'http://localhost:3000/api' // production api
    }
}

module.exports = nextConfig
 

Package.json

Path: /package.json

The package.json file contains project configuration information including scripts for running and building the Next.js tutorial app, and dependencies that get installed when you run npm install or npm i. Full documentation is available on the npm docs website.

For more info on the Next.js CLI commands used in the package.json scripts see https://nextjs.org/docs/api-reference/cli.

{
    "name": "next-js-registration-login-example",
    "version": "0.1.0",
    "private": true,
    "scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start",
        "lint": "next lint"
    },
    "dependencies": {
        "@hookform/resolvers": "^3.0.0",
        "bcryptjs": "^2.4.3",
        "eslint": "8.37.0",
        "eslint-config-next": "13.2.4",
        "express-jwt": "^8.4.1",
        "jsonwebtoken": "^9.0.0",
        "mongodb": "^5.2.0",
        "mongoose": "^7.0.3",
        "next": "13.2.4",
        "prop-types": "^15.8.1",
        "react": "18.2.0",
        "react-dom": "18.2.0",
        "react-hook-form": "^7.43.9",
        "rxjs": "^7.8.0",
        "yup": "^1.0.2"
    }
}


Other versions of this tutorial

The registration and login tutorial is also available in the following versions:

 


Need Some NextJS Help?

Search fiverr for freelance NextJS developers.


Follow me for updates

On Twitter or RSS.


When I'm not coding...

Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!


Comments


Supported by