Published: July 28 2023

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

Tutorial built with Next.js 13.4.12 with the App Router, React 18.2.0, TypeScript 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.

The app is built with TypeScript and the new App Router that was introduced in Next.js 13.

Tutorial contents

 

Example Next.js + MongoDB Auth App Overview

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

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 Secure Layout Component which redirects unauthenticated users to the login page.

Next.js Tutorial Back-End API

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

  • /api/account/login - POST - public route for authenticating username and password and generating a JWT token that is returned in an HTTP only authorization cookie.
  • /api/account/logout - POST - public route for logging out that deletes the HTTP only authorization cookie.
  • /api/account/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 - POST - secure route for creating a new user.
  • /api/users/current - GET - secure route that returns the current logged in user.
  • /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 are protected by the JWT Middleware that verifies the JWT token in the authorization cookie of the request.

Next.js App Router

The new App Router is similar to the existing Pages Router in that they are both file-system based routers where folders and files are used to define page and API routes in Next.js applications.

A different file naming convention is used to define routes with the Next.js Pages Router vs the App Router. For example if you want to create a login form with the route /account/login, using the Pages Router you would create the file /pages/account/login.jsx, whereas with the App Router you would create the file /app/account/login/page.jsx (or .tsx with TypeScript).

The App Router adds support for new features including React Server Components, Layouts, Route Groups and Private Folders.

  • React Server Components are rendered on the server, I think of them working similar to traditional server-side rendered web pages (e.g. .NET MVC, PHP etc).
  • Layouts allow you to put common layout code in one place for multiple pages, this wasn't supported with the Pages Router and required workarounds.
  • Route Groups allow you to group pages in folders without the folder name being included in the URL path. To do this wrap the folder name in parentheses (e.g. (secure), (public)).
  • Private Folders allow you to create folders in the /app directory that are ignored by the routing system. To do this prefix the folder name with an underscore (e.g. _components, _services).

To use the App Router place files in the /app folder, to use the Pages Router place files in the /pages folder. It's possible to use both in the same project but this should only be required if you're migrating a project from the Pages Router to the App Router.

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

Forms in the example are 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 and Next.js 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.

Zustand State Management

Zustand is a popular light-weight React state management library used by the user service and alert service to manage shared state that can be accessed by different components in the application. For more info see https://docs.pmnd.rs/zustand/getting-started/introduction.

Joi Schema Validation

Joi is a flexible JavaScript schema validation library used to validate data sent in HTTP requests to the Next.js API (e.g. the login route and register route). For more info see Request Schema Validation with Joi or https://joi.dev/.

Bootstrap CSS

Bootstrap 5 CSS is used for styling the Next.js tutorial app, it is imported in the Root Layout component. For more info see https://getbootstrap.com/.

Code on GitHub

The example project is available on GitHub at https://github.com/cornflourblue/next-js-13-app-router-mongodb-rego-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 + App Router + MongoDB Auth App on your local machine:

  1. Download or clone the Next.js project source code from https://github.com/cornflourblue/next-js-13-app-router-mongodb-rego-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/app/api-reference/next-cli.

Before running in production

Before running the app in production make sure you update the JWT_SECRET environment variable in the .env 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 Next.js app or 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

Inside the top-level Next.js App Router folder (/app), 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/users). The folder name is prefixed with an underscore (_) to mark it as a Private Folder which is ignored by the Next.js routing system.
  • _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 /_helpers/client folder, server-side helpers are in the /_helpers/server folder and API specific helpers are in the /_helpers/server/api folder. The folder name is prefixed with an underscore (_) to mark it as a Private Folder which is ignored by the Next.js routing system.
  • _services
    Services encapsulate client-side logic and handle HTTP communication between the React front-end app to the Next.js back-end API. Each service encapsulates logic for a specific content type (e.g. the user service) and exposes properties and methods for performing various operations (e.g. auth and CRUD operations). Services can also perform actions that don't involve HTTP requests, such as displaying and clearing alerts with the alert service. The folder name is prefixed with an underscore (_) to mark it as a Private Folder which is ignored by the Next.js routing system.
  • (public)
    Public pages including the login page and register page. The folder name is wrapped in parentheses () to mark it as a Route Group so it isn't included in the URL path.
  • (secure)
    Secure pages that are only accessible to authenticated users. Unauthenticated users are automatically redirected to the login page by the Secure Layout. The folder name is wrapped in parentheses () to mark it as a Route Group so it isn't included in the URL path.
  • api
    API route handlers for the Next.js login tutorial app. Route handlers are all wrapped with the custom API Handler function to add support for middleware and global error handling.
    NOTE: I tried this with the new App Router Middleware but it doesn't seem to support error handling by wrapping NextResponse.next() in a try/catch block (like express middleware).

TypeScript code structure

I've organised TypeScript files 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 TS module.

Barrel files

The index.ts 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 { Alert, Nav } from '_components';).

Base URL for imports

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

Next.js Project structure

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

 

Users Add/Edit Component

Path: /app/_components/users/AddEdit.tsx

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.

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/docs/useform.

Form validation rules are defined with options passed to the React Hook Form register() function that is used to register each form input field. For a full list of validation options available see https://react-hook-form.com/docs/useform/register#options.

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

The form is in edit mode when there is a user object passed in the component props, otherwise it is in add mode. In edit mode the user details are preloaded into the form fields with the React Hook Form defaultValues option. The password field is required in add mode and optional in edit mode, this is controlled with the custom validate function of the password field.

The returned TSX/JSX template contains the form with all of the input fields and validation messages. The form fields are bound to each input element with the JS spread operater (e.g. {...fields.firstName}).

'use client';

import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useForm } from 'react-hook-form';

import { useAlertService, useUserService } from '_services';

export { AddEdit };

function AddEdit({ title, user }: { title: string, user?: any }) {
    const router = useRouter();
    const alertService = useAlertService();
    const userService = useUserService();

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

    const fields = {
        firstName: register('firstName', { required: 'First Name is required' }),
        lastName: register('lastName', { required: 'Last Name is required' }),
        username: register('username', { required: 'Username is required' }),
        password: register('password', {
            minLength: { value: 6, message: 'Password must be at least 6 characters' },
            // password only required in add mode
            validate:  value => !user && !value ? 'Password is required' : undefined
        })
    };

    async function onSubmit(data: any) {
        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.create(data);
                message = 'User added';
            }

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

    return (
        <form onSubmit={handleSubmit(onSubmit)}>
            <h1>{title}</h1>
            <div className="row">
                <div className="mb-3 col">
                    <label className="form-label">First Name</label>
                    <input {...fields.firstName} type="text" className={`form-control ${errors.firstName ? 'is-invalid' : ''}`} />
                    <div className="invalid-feedback">{errors.firstName?.message?.toString()}</div>
                </div>
                <div className="mb-3 col">
                    <label className="form-label">Last Name</label>
                    <input {...fields.lastName} type="text" className={`form-control ${errors.lastName ? 'is-invalid' : ''}`} />
                    <div className="invalid-feedback">{errors.lastName?.message?.toString()}</div>
                </div>
            </div>
            <div className="row">
                <div className="mb-3 col">
                    <label className="form-label">Username</label>
                    <input {...fields.username} type="text" className={`form-control ${errors.username ? 'is-invalid' : ''}`} />
                    <div className="invalid-feedback">{errors.username?.message?.toString()}</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 {...fields.password} type="password" className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
                    <div className="invalid-feedback">{errors.password?.message?.toString()}</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()} type="button" disabled={formState.isSubmitting} className="btn btn-secondary">Reset</button>
                <Link href="/users" className="btn btn-link">Cancel</Link>
            </div>
        </form>
    );
}
 

Alert Component

Path: /app/_components/Alert.tsx

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 useEffect() hook clears the alert on location change by including a dependency on the current pathname.

'use client';

import { useEffect } from 'react';
import { usePathname } from 'next/navigation';

import { useAlertService } from '_services';

export { Alert };

function Alert() {
    const pathname = usePathname();
    const alertService = useAlertService();
    const alert = alertService.alert;
    
    useEffect(() => {
        // clear alert on location change
        alertService.clear();
    }, [pathname]);

    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>
    );
}
 
Path: /app/_components/Nav.tsx

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.

'use client';

import { useState } from 'react';

import { NavLink } from '_components';
import { useUserService } from '_services';

export { Nav };

function Nav() {
    const [loggingOut, setLoggingOut] = useState<boolean>(false);
    const userService = useUserService();

    async function logout() {
        setLoggingOut(true);
        await userService.logout();
    }

    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={logout} className="btn btn-link nav-item nav-link" style={{ width: '67px' }} disabled={loggingOut}>
                    {loggingOut
                        ? <span className="spinner-border spinner-border-sm"></span>
                        : <span>Logout</span>
                    }
                </button>
            </div>
        </nav>
    );
}
 
Path: /app/_components/NavLink.tsx

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

The INavLink interface defines the properties for a NavLink component. The [key: string]: any property on the interface adds support for the rest/spread parameter (...props) so any other attributes can be added to the returned Link component.

'use client';

import { usePathname } from 'next/navigation';
import Link from 'next/link';

export { NavLink };

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

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

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

interface INavLink {
    children: React.ReactNode,
    href: string,
    exact?: boolean,
    [key: string]: any
}
 

Spinner Component

Path: /app/_components/Spinner.tsx

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

export { Spinner };

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

Fetch React Hook

Path: /app/_helpers/client/useFetch.ts

The useFetch hook 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 the user is automatically logged out of the Next.js app.

The fetch hook allows you to send a POST request as simply as this: fetch.post(url, body). It's used in the example app by the user service.

import { useRouter } from 'next/navigation';

export { useFetch };

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

    return {
        get: request('GET'),
        post: request('POST'),
        put: request('PUT'),
        delete: request('DELETE')
    };

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

    // helper functions

    async function handleResponse(response: any) {
        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 (response.status === 401) {
                // api auto logs out on 401 Unauthorized, so redirect to login page
                router.push('/account/login');
            }

            // 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 API Handler

Path: /app/_helpers/server/api/api-handler.ts

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

It enables adding global middleware to the Next.js API 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.

NextRequest json() method can only be called once

The req.json() method can only be called once to read the JSON body of an API request, which causes a problem if you need to read it twice (e.g. from middleware and from the route handler). To get around this limitation I replaced (a.k.a. monkey patched) the json() method with a function that returns a stored copy of the JSON body from the first call.

Global API middleware

The JWT middleware verifies the auth token cookie for secure API routes.

The validate middleware validates the JSON request body against a schema (if provided). Validation schemas are created with the Joi library and attached as a .schema property to route handler functions where required (e.g. the login route handler).

Global API exception handler

The error handler is used to handle all API exceptions in a single place.

Why not use the new Next.js 13 App Router Middleware?

I tried to implement all of the API handler functionality with the new Next.js App Router Middleware but it doesn't seem to support global error handling with a try/catch block like below.

import { NextRequest, NextResponse } from 'next/server';

import { errorHandler, jwtMiddleware, validateMiddleware } from './';

export { apiHandler };

function apiHandler(handler: any) {
    const wrappedHandler: any = {};
    const httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];

    // wrap handler methods to add middleware and global error handler
    httpMethods.forEach(method => {
        if (typeof handler[method] !== 'function')
            return;

        wrappedHandler[method] = async (req: NextRequest, ...args: any) => {
            try {
                // monkey patch req.json() because it can only be called once
                const json = await req.json();
                req.json = () => json;
            } catch {}

            try {
                // global middleware
                await jwtMiddleware(req);
                await validateMiddleware(req, handler[method].schema);

                // route handler
                const responseBody = await handler[method](req, ...args);
                return NextResponse.json(responseBody || {});
            } catch (err: any) {
                // global error handler
                return errorHandler(err);
            }
        };
    });

    return wrappedHandler;
}
 

Next.js API Global Error Handler

Path: /app/_helpers/server/api/error-handler.ts

The global error handler is used catch all API 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 'JsonWebTokenError' it means JWT token validation has failed so an HTTP 401 unauthorized response code is returned with the message 'Unauthorized'. The auth cookie is also deleted (if there is one) to automatically log the user out.

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

import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';

export { errorHandler };

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

    if (err.name === 'JsonWebTokenError') {
        // jwt error - delete cookie to auto logout
        cookies().delete('authorization');
        return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
    }

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

Next.js API JWT Middleware

Path: /app/_helpers/server/api/jwt-middleware.ts

The JWT middleware uses the auth helper to verify the JWT token if the request is to a secure API route (public routes are bypassed). If the token is invalid an error is thrown which causes the global error handler to return a 401 Unauthorized response.

The middleware is added to the Next.js request pipeline in the API handler wrapper function.

The current user id is added to the request headers to make it accessible to any other code in the request (e.g. the getCurrent() method of the users repo).

import { NextRequest } from 'next/server';

import { auth } from '_helpers/server';

export { jwtMiddleware };

async function jwtMiddleware(req: NextRequest) {
    if (isPublicPath(req))
        return;

    // verify token in request cookie
    const id = auth.verifyToken();
    req.headers.set('userId', id);
}

function isPublicPath(req: NextRequest) {
    // public routes that don't require authentication
    const publicPaths = [
        'POST:/api/account/login',
        'POST:/api/account/logout',
        'POST:/api/account/register'
    ];
    return publicPaths.includes(`${req.method}:${req.nextUrl.pathname}`);
}
 

Next.js API Validate Middleware

Path: /app/_helpers/server/api/validate-middleware.ts

The validate middleware uses the Joi library to validate JSON data in the request body against rules defined in a schema. If validation fails an error is thrown with a comma separated list of all the error messages.

Validation schemas are assigned to the schema property of route handler functions where required (e.g. the register route handler). The req and schema parameters are passed by the API handler when it calls validateMiddleware() for each request to the Next.js API. If there is no schema the middleware is bypassed.

import joi from 'joi';

export { validateMiddleware };

async function validateMiddleware(req: Request, schema: joi.ObjectSchema) {
    if (!schema) return;

    const options = {
        abortEarly: false, // include all errors
        allowUnknown: true, // ignore unknown props
        stripUnknown: true // remove unknown props
    };

    const body = await req.json();
    const { error, value } = schema.validate(body, options);

    if (error) {
        throw `Validation error: ${error.details.map(x => x.message).join(', ')}`;
    }

    // update req.json() to return sanitized req body
    req.json = () => value;    
}
 

Auth Helper

Path: /app/_helpers/server/auth.ts

The auth helper is used to verify the JWT token in the request 'authorization' cookie.

The verifyToken function verifies the JWT auth token using the jsonwebtoken library. In the example it is called from the JWT middleware. On success the decoded user id from the auth token is returned. On fail an exception ('JsonWebTokenError') is thrown by the jwt.verify() method.

The isAuthenticated function is a simple wrapper that executes verifyToken() in a try/catch block to return the current authentication status as a boolean (true or false). It is used in the secure layout and account layout components.

import jwt from 'jsonwebtoken';
import { cookies } from 'next/headers';

export const auth = {
    isAuthenticated,
    verifyToken
}

function isAuthenticated() {
    try {
        verifyToken();
        return true;
    } catch {
        return false;
    }
}

function verifyToken() {
    const token = cookies().get('authorization')?.value ?? '';
    const decoded = jwt.verify(token, process.env.JWT_SECRET!);
    const id = decoded.sub as string;
    return id;
}
 

MongoDB Data Context

Path: /app/_helpers/server/db.ts

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 read/write to any part of the database from a single point.

The database connection string (MONGODB_URI) environment variable is defined in the .env file.

import mongoose from 'mongoose';

const Schema = mongoose.Schema;

mongoose.connect(process.env.MONGODB_URI!);
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);
}
 

MongoDB Users Repo

Path: /app/_helpers/server/users-repo.ts

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 and account API route handlers (e.g. account login route, users [id] route).

User authentication

The authenticate method verifies the provided username and password. On success a JWT (JSON Web Token) is generated with the jsonwebtoken npm package, the token is digitally signed using a secret key (JWT_SECRET) so it can't be tampered with, the jwt secret is defined in the .env file.

The JWT is returned to the client in an HTTP Only 'authorization' cookie by the login route handler, the cookie is then automatically sent with subsequent requests from the browser to the API.

Password encryption

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 jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { headers } from 'next/headers';
import { db } from './db';

const User = db.User;

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

async function authenticate({ username, password }: { username: string, password: string }) {
    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 }, process.env.JWT_SECRET!, { expiresIn: '7d' });

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

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

async function getById(id: string) {
    try {
        return await User.findById(id);
    } catch {
        throw 'User Not Found';
    }
}

async function getCurrent() {
    try {
        const currentUserId = headers().get('userId');
        return await User.findById(currentUserId);
    } catch {
        throw 'Current User Not Found';
    }
}

async function create(params: any) {
    // 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: string, params: any) {
    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: string) {
    await User.findByIdAndRemove(id);
}
 

Alert Service React Hook

Path: /app/_services/useAlertService.ts

The alert service is a React hook that acts as the bridge between any component in the Next.js tutorial app and the alert component that displays notifications. It contains methods for displaying and clearing alerts, and an alert property to access the current alert.

Displaying an alert

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 is an optional showAfterRedirect boolean parameter that keeps the alert displayed after one route change (e.g. the redirect after successful registration). Default value is false.

Zustand state management

Zustand is used internally by the alert service to manage shared state data (alert) so it can be accessed by any component in the application. It's a light-weight alternative to Redux for React state management, for more info see https://docs.pmnd.rs/zustand/getting-started/introduction.

TypeScript interfaces

TypeScript interfaces are used to define the properties and methods of an alert object (IAlert), the zustand alert state store (IAlertStore), and the alert service (IAlertService). The alert service interface extends the alert store interface to include the alert property in service interface.

import { create } from 'zustand';

export { useAlertService };

// alert state store
const alertStore = create<IAlertStore>(() => ({}));

function useAlertService(): IAlertService {
    const { alert } = alertStore();

    return {
        alert,
        success: (message: string, showAfterRedirect = false) => {
            const type = 'alert-success';
            alertStore.setState({
                alert: { type, message, showAfterRedirect }
            });
        },
        error: (message: string, showAfterRedirect = false) => {
            const type = 'alert-danger';
            alertStore.setState({
                alert: { type, message, showAfterRedirect }
            });
        },
        clear: () => {
            alertStore.setState(state => {
                let alert = state.alert;
    
                // if showAfterRedirect is true the alert is kept for
                // one route change (e.g. after successful registration)
                if (alert?.showAfterRedirect) {
                    alert.showAfterRedirect = false;
                } else {
                    alert = undefined;
                }
    
                return { alert };
            });
        }
    }
}

// interfaces

interface IAlert {
    type: string,
    message: string,
    showAfterRedirect: boolean
}

interface IAlertStore {
    alert?: IAlert
}

interface IAlertService extends IAlertStore {
    success: (message: string, showAfterRedirect?: boolean) => void,
    error: (message: string, showAfterRedirect?: boolean) => void,
    clear: () => void,
}
 

User Service React Hook

Path: /app/_services/useUserService.ts

The user service is a React hook that encapsulates client-side logic and handles HTTP communication between the React front-end and the Next.js back-end 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, creating and updating user data. HTTP requests are sent with the help of the useFetch hook.

Zustand state management

Zustand is a light-weight React state management library used internally by the user service to manage shared state properties (users, user and currentUser) so they can be accessed by any component in the application.

For example the users list page displays all users by calling userService.getAll() on load and reading the userService.users property. The getAll() method fetches all users from the API and assigns them to the users shared state property (with userStore.setState()) which triggers a re-render of the page.

The getById() and getCurrent() methods work the same way by fetching and setting the user and currentUser shared state properties.

TypeScript interfaces

TypeScript interfaces are used to define the properties and methods of a user object (IUser), the zustand user state store (IUserStore), and the user service (IUserService). The user service interface extends the user store interface to include the shared state properties (users, user and currentUser) in service interface.

import { create } from 'zustand';
import { useRouter, useSearchParams } from 'next/navigation';

import { useAlertService } from '_services';
import { useFetch } from '_helpers/client';

export { useUserService };

// user state store
const initialState = {
    users: undefined,
    user: undefined,
    currentUser: undefined
};
const userStore = create<IUserStore>(() => initialState);

function useUserService(): IUserService {
    const alertService = useAlertService();
    const fetch = useFetch();
    const router = useRouter();
    const searchParams = useSearchParams();
    const { users, user, currentUser } = userStore();

    return {
        users,
        user,
        currentUser,
        login: async (username, password) => {
            alertService.clear();
            try {
                const currentUser = await fetch.post('/api/account/login', { username, password });
                userStore.setState({ ...initialState, currentUser });

                // get return url from query parameters or default to '/'
                const returnUrl = searchParams.get('returnUrl') || '/';
                router.push(returnUrl);
            } catch (error: any) {
                alertService.error(error);
            }
        },
        logout: async () => {
            await fetch.post('/api/account/logout');
            router.push('/account/login');
        },
        register: async (user) => {
            try {
                await fetch.post('/api/account/register', user);
                alertService.success('Registration successful', true);
                router.push('/account/login');
            } catch (error: any) {
                alertService.error(error);
            }
        },
        getAll: async () => {
            userStore.setState({ users: await fetch.get('/api/users') });
        },
        getById: async (id) => {
            userStore.setState({ user: undefined });
            try {
                userStore.setState({ user: await fetch.get(`/api/users/${id}`) });
            } catch (error: any) {
                alertService.error(error);
            }
        },
        getCurrent: async () => {
            if (!currentUser) {
                userStore.setState({ currentUser: await fetch.get('/api/users/current') });
            }
        },
        create: async (user) => {
            await fetch.post('/api/users', user);
        },
        update: async (id, params) => {
            await fetch.put(`/api/users/${id}`, params);

            // update current user if the user updated their own record
            if (id === currentUser?.id) {
                userStore.setState({ currentUser: { ...currentUser, ...params } })
            }
        },
        delete: async (id) => {
            // set isDeleting prop to true on user
            userStore.setState({
                users: users!.map(x => {
                    if (x.id === id) { x.isDeleting = true; }
                    return x;
                })
            });

            // delete user
            const response = await fetch.delete(`/api/users/${id}`);

            // remove deleted user from state
            userStore.setState({ users: users!.filter(x => x.id !== id) });

            // logout if the user deleted their own record
            if (response.deletedSelf) {
                router.push('/account/login');
            }
        }
    }
};

// interfaces

interface IUser {
    id: string,
    firstName: string,
    lastName: string,
    username: string,
    password: string,
    isDeleting?: boolean
}

interface IUserStore {
    users?: IUser[],
    user?: IUser,
    currentUser?: IUser
}

interface IUserService extends IUserStore {
    login: (username: string, password: string) => Promise<void>,
    logout: () => Promise<void>,
    register: (user: IUser) => Promise<void>,
    getAll: () => Promise<void>,
    getById: (id: string) => Promise<void>,
    getCurrent: () => Promise<void>,
    create: (user: IUser) => Promise<void>,
    update: (id: string, params: Partial<IUser>) => Promise<void>,
    delete: (id: string) => Promise<void>
}
 

Next.js Login Form

Path: /app/(public)/account/login/page.tsx

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.

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/docs/useform.

Form validation rules are defined with options passed to the React Hook Form register() function that is used to register each form input field. For a full list of validation options available see https://react-hook-form.com/docs/useform/register#options.

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 TSX/JSX template contains the form with all of the input fields and validation messages. The form fields are bound to each input element with the JS spread operater (e.g. {...fields.username}).

'use client';

import Link from 'next/link';
import { useForm } from 'react-hook-form';

import { useUserService } from '_services';

export default Login;

function Login() {
    const userService = useUserService();

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

    const fields = {
        username: register('username', { required: 'Username is required' }),
        password: register('password', { required: 'Password is required' })
    };

    async function onSubmit({ username, password }: any) {
        await userService.login(username, password);
    }

    return (
        <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 {...fields.username} type="text" className={`form-control ${errors.username ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.username?.message?.toString()}</div>
                    </div>
                    <div className="mb-3">
                        <label className="form-label">Password</label>
                        <input {...fields.password} type="password" className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.password?.message?.toString()}</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>
    );
}
 

Next.js Registration Form

Path: /app/(public)/account/register/page.tsx

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.

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/docs/useform.

Form validation rules are defined with options passed to the React Hook Form register() function that is used to register each form input field. For a full list of validation options available see https://react-hook-form.com/docs/useform/register#options.

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 TSX/JSX template contains the form with all of the input fields and validation messages. The form fields are bound to each input element with the JS spread operater (e.g. {...fields.username}).

'use client';

import Link from 'next/link';
import { useForm } from 'react-hook-form';

import { useUserService } from '_services';

export default Register;

function Register() {
    const userService = useUserService();

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

    const fields = {
        firstName: register('firstName', { required: 'First Name is required' }),
        lastName: register('lastName', { required: 'Last Name is required' }),
        username: register('username', { required: 'Username is required' }),
        password: register('password', {
            required: 'Password is required',
            minLength: { value: 6, message: 'Password must be at least 6 characters' }
        })
    }

    async function onSubmit(user: any) {
        await userService.register(user);
    }

    return (
        <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 {...fields.firstName} type="text" className={`form-control ${errors.firstName ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.firstName?.message?.toString()}</div>
                    </div>
                    <div className="mb-3">
                        <label className="form-label">Last Name</label>
                        <input {...fields.lastName} type="text" className={`form-control ${errors.lastName ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.lastName?.message?.toString()}</div>
                    </div>
                    <div className="mb-3">
                        <label className="form-label">Username</label>
                        <input {...fields.username} type="text" className={`form-control ${errors.username ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.username?.message?.toString()}</div>
                    </div>
                    <div className="mb-3">
                        <label className="form-label">Password</label>
                        <input {...fields.password} type="password" className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.password?.message?.toString()}</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>
    );
}
 

Next.js Account Layout

Path: /app/(public)/account/layout.tsx

The account layout component contains common layout code for all pages in the /app/(public)/account folder, it contains an <Alert /> component above the {children} elements which are wrapped in a div with some bootstrap classes to set the width and alignment of all account pages.

Auth status is checked at the top to automatically redirect the user to the home page if they are already logged in.

import { redirect } from 'next/navigation';

import { auth } from '_helpers/server';
import { Alert } from '_components';

export default Layout;

function Layout({ children }: { children: React.ReactNode }) {
    // if logged in redirect to home page
    if (auth.isAuthenticated()) {
        redirect('/');
    }

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

Add User Page

Path: /app/(secure)/users/add/page.tsx

The add user page simply renders the add/edit user component without any user specified so the component is set to "add" mode.

import { AddEdit } from '_components/users';

export default Add;

function Add() {
    return <AddEdit title="Add User" />;
}
 

Edit User Page

Path: /app/(secure)/users/edit/[id]/page.tsx

The edit user page renders the add/edit user component with the specified user so the component is set 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 component params.

'use client';

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

import { AddEdit } from '_components/users';
import { Spinner } from '_components';
import { useUserService } from '_services';

export default Edit;

function Edit({ params: { id } }: any) {
    const router = useRouter();
    const userService = useUserService();
    const user = userService.user;

    useEffect(() => {
        if (!id) return;

        // fetch user for add/edit form
        userService.getById(id)
    }, [router]);

    return user
        ? <AddEdit title="Edit User" user={user} />
        : <Spinner />;
}
 

Users List Page

Path: /app/(secure)/users/page.tsx

The users list page displays a list of all users in the Next.js tutorial app and contains buttons for adding, editing and deleting users.

All users are fetched in a useEffect() hook with the user service, and accessed via the userService.users property.

The delete button calls the userService.deleteUser() method. The method sets the user isDeleting property so the UI displays a spinner on the delete button while the user is deleted from the API. If the current user deletes their own record they are automatically logged out of the app.

'use client';

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

import { Spinner } from '_components';
import { useUserService } from '_services';

export default Users;

function Users() {
    const userService = useUserService();
    const users = userService.users;

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

    return (
        <>
            <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>
                    <TableBody />
                </tbody>
            </table>
        </>
    );

    function TableBody() {
        if (users?.length) {
            return (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={() => userService.delete(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>
            ));
        }

        if (!users) {
            return (
                <tr>
                    <td colSpan={4}>
                        <Spinner />
                    </td>
                </tr>
            );
        }

        if (users?.length === 0) {
            return (
                <tr>
                    <td colSpan={4} className="text-center">
                        <div className="p-2">No Users To Display</div>
                    </td>
                </tr>
            );
        }
    }
}
 

Next.js Secure Layout

Path: /app/(secure)/layout.tsx

The secure layout component contains common layout code for all pages in the /app/(secure) folder, it contains the main <Nav /> component and an <Alert /> component above the {children} elements which are wrapped in a couple of divs with bootstrap classes to set the padding and alignment of all of the secure pages.

Auth status is checked at the top to automatically redirect unauthenticated users to the login page along with the request path in the query params (returnUrl). The request path is accessible in server components via the Next.js request header x-invoke-path.

import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

import { auth } from '_helpers/server';
import { Alert, Nav } from '_components';

export default Layout;

function Layout({ children }: { children: React.ReactNode }) {
    // if not logged in redirect to login page
    if (!auth.isAuthenticated()) {
        const returnUrl = encodeURIComponent(headers().get('x-invoke-path') || '/');
        redirect(`/account/login?returnUrl=${returnUrl}`);
    }

    return (
        <div className="app-container bg-light">
            <Nav />
            <Alert />
            <div className="p-4">
                <div className="container">
                    {children}
                </div>
            </div>
        </div>
    );
}
 

Home Page

Path: /app/(secure)/page.tsx

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

The current user is fetched in a useEffect() hook with the user service, and accessed via the userService.currentUser property.

'use client';

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

import { useUserService } from '_services';
import { Spinner } from '_components';

export default Home;

function Home() {
    const userService = useUserService();
    const user = userService.currentUser;

    useEffect(() => {
        userService.getCurrent();
    }, []);

    if (user) {
        return (
            <>
                <h1>Hi {user.firstName}!</h1>
                <p>You&apos;re logged in with Next.js & JWT!!</p>
                <p><Link href="/users">Manage Users</Link></p>
            </>
        );
    } else {
        return <Spinner />;
    }
}
 

Next.js API Login Route

Path: /app/api/account/login/route.ts

The login route handler receives HTTP requests sent to the login route /api/account/login. It supports HTTP POST requests containing a username and password which are authenticated by the usersRepo.authenticate() function. On success a JWT auth token is set in an HTTP only 'authorization' cookie.

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

The request schema is defined with the joi data validation library and assigned to the login.schema property. Schema validation is handled by the validate middleware which is called by the api handler.

import { cookies } from 'next/headers';
import joi from 'joi';

import { usersRepo } from '_helpers/server';
import { apiHandler } from '_helpers/server/api';

module.exports = apiHandler({
    POST: login
});

async function login(req: Request) {
    const body = await req.json();
    const { user, token } = await usersRepo.authenticate(body);

    // return jwt token in http only cookie
    cookies().set('authorization', token, { httpOnly: true });

    return user;
}

login.schema = joi.object({
    username: joi.string().required(),
    password: joi.string().required()
});
 

Next.js API Logout Route

Path: /app/api/account/logout/route.ts

The logout route simply deletes the 'authorization' cookie to log the user out. The cookie is HTTP only which means it is only accessible on the server, this improves security by preventing cross site scripting (XSS) where malicious client side code can attempt to access private data like cookies.

To logout the client side user service sends a request to this API route and then redirects to the login page.

import { cookies } from 'next/headers';

import { apiHandler } from '_helpers/server/api';

module.exports = apiHandler({
    POST: logout
});

function logout() {
    cookies().delete('authorization');
}
 

Next.js API Register Route

Path: /app/api/account/register/route.ts

The register handler receives HTTP requests sent to the register route /api/account/register. It supports HTTP POST requests containing user details which are registered in the Next.js tutorial app by the usersRepo.create() method.

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

The request schema is defined with the joi data validation library and assigned to the register.schema property. Schema validation is handled by the validate middleware which is called by the api handler.

import joi from 'joi';

import { usersRepo } from '_helpers/server';
import { apiHandler } from '_helpers/server/api';

module.exports = apiHandler({
    POST: register
});

async function register(req: Request) {
    const body = await req.json();
    await usersRepo.create(body);
}

register.schema = joi.object({
    firstName: joi.string().required(),
    lastName: joi.string().required(),
    username: joi.string().required(),
    password: joi.string().min(6).required(),
});
 

Users [id] API Route

Path: /app/api/users/[id]/route.ts

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 params object which is passed to the route handler.

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

The request schema for the update() function is defined with the joi data validation library and assigned to the update.schema property. Schema validation is handled by the validate middleware which is called by the api handler.

import joi from 'joi';

import { cookies } from 'next/headers';

import { apiHandler } from '_helpers/server/api';
import { usersRepo } from '_helpers/server';

module.exports = apiHandler({
    GET: getById,
    PUT: update,
    DELETE: _delete
});

async function getById(req: Request, { params: { id } }: any) {
    return await usersRepo.getById(id);
}

async function update(req: Request, { params: { id } }: any) {
    const body = await req.json();
    await usersRepo.update(id, body);
}

update.schema = joi.object({
    firstName: joi.string(),
    lastName: joi.string(),
    username: joi.string(),
    password: joi.string().min(6).allow(''),
});

async function _delete(req: Request, { params: { id } }: any) {
    await usersRepo.delete(id);

    // auto logout if deleted self
    if (id === req.headers.get('userId')) {
        cookies().delete('authorization');
        return { deletedSelf: true };
    }
}
 

Current User API Route

Path: /app/api/users/current/route.ts

The current user handler receives HTTP requests sent to the current user route /api/users/current. It supports HTTP GET requests by passing an object with that method name to the apiHandler() which maps it to the getCurrent() function.

The getCurrent() function returns the current logged in user details from MongoDB by calling usersRepo.getCurrent().

import { usersRepo } from '_helpers/server';
import { apiHandler } from '_helpers/server/api';

module.exports = apiHandler({
    GET: getCurrent
});

async function getCurrent() {
    return await usersRepo.getCurrent();
}
 

Users API Route

Path: /app/api/users/route.ts

The users handler receives HTTP requests sent to the base users route /api/users. It supports HTTP GET and POST requests by passing an object with those method names to the apiHandler() which maps them to the functions getAll() and create().

The getAll() function returns all users from MongoDB by calling usersRepo.getAll().

The create() function creates a new user in MongoDB by calling usersRepo.create().

The request schema for the create() function is defined with the joi data validation library and assigned to the create.schema property. Schema validation is handled by the validate middleware which is called by the api handler.

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

import joi from 'joi';

import { usersRepo } from '_helpers/server';
import { apiHandler } from '_helpers/server/api';

module.exports = apiHandler({
    GET: getAll,
    POST: create
});

async function getAll() {
    return await usersRepo.getAll();
}

async function create(req: Request) {
    const body = await req.json();
    await usersRepo.create(body);
}

create.schema = joi.object({
    firstName: joi.string().required(),
    lastName: joi.string().required(),
    username: joi.string().required(),
    password: joi.string().min(6).required(),
});
 

Global CSS

Path: /app/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 root layout component.

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

Next.js Root Layout

Path: /app/layout.tsx

The root layout component contains the outer <html> and <body> tags for the Next.js app, SEO metadata elements in the <head> tag (e.g. title and description) are controlled with the exported metadata object. Global CSS stylesheets are imported at the top of the file.

import 'bootstrap/dist/css/bootstrap.min.css';
import 'globals.css';

export const metadata = {
    title: 'Next.js 13 - User Registration and Login Example'
}

export default Layout;

function Layout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="en">
            <body>
                {children}
            </body>
        </html>
    );
}
 

Dotenv (.env) file

Path: /app/.env

The .env file contains environment variables that are accessible to code in the Next.js app via the process.env object.

By default environment variables are only accessible to server-side code, to make env vars accessible to the React client app they must be prefixed with NEXT_PUBLIC_. For more info see Next.js - Access Environment Variables from dotenv (.env).

Multiple dotenv files can be created to configure different environments. .env is the base file that applies to all environments, you can also create .env.development for dev specific variables, .env.production for prod and .env.local for the local environment.

IMPORTANT: The JWT_SECRET environment variable 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 Next.js app or 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/).

JWT_SECRET=THIS IS USED TO SIGN AND VERIFY JWT TOKENS, REPLACE IT WITH YOUR OWN SECRET, IT CAN BE ANY STRING
MONGODB_URI=mongodb://127.0.0.1/next-js-registration-login-example
 

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/app/api-reference/create-next-app.

{
    "extends": "next/core-web-vitals"
}
 

Next.js Config

Path: /next.config.js

The Next.js config file can be used to configure different options for the Next.js project.

The file was automatically generated when I created the Next.js base project with the command npx create-next-app@latest and I haven't made any changes. For info on all of the config options available see https://nextjs.org/docs/app/api-reference/next-config-js

/** @type {import('next').NextConfig} */
const nextConfig = {}

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/app/api-reference/next-cli.

{
    "name": "next-js-13-app-router-mongodb-rego-login-example",
    "version": "0.1.0",
    "private": true,
    "scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start",
        "lint": "next lint"
    },
    "dependencies": {
        "@types/bcryptjs": "^2.4.2",
        "@types/jsonwebtoken": "^9.0.2",
        "@types/node": "20.4.5",
        "@types/react": "18.2.16",
        "@types/react-dom": "18.2.7",
        "bcryptjs": "^2.4.3",
        "bootstrap": "^5.3.0",
        "eslint": "8.45.0",
        "eslint-config-next": "13.4.12",
        "joi": "^17.9.2",
        "jsonwebtoken": "^9.0.1",
        "mongodb": "^5.7.0",
        "mongoose": "^7.4.1",
        "next": "13.4.12",
        "react": "18.2.0",
        "react-dom": "18.2.0",
        "react-hook-form": "^7.45.2",
        "typescript": "5.1.6",
        "zustand": "^4.3.9"
    }
}
 

TypeScript Config

Path: /tsconfig.json

The tsconfig file contains compiler options and configuration that specify how the TypeScript project is compiled into JavaScript.

The file was automatically generated when I created the base Next.js project with the command npx create-next-app@latest with the TypeScript option. The only change I made was adding the baseUrl.

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

Next.js also supports module path aliases(e.g. '@/_services') in the tsconfig file, for more info see https://nextjs.org/docs/app/building-your-application/configuring/absolute-imports-and-module-aliases.

{
    "compilerOptions": {
        "target": "es5",
        "lib": [
            "dom",
            "dom.iterable",
            "esnext"
        ],
        "allowJs": true,
        "skipLibCheck": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "noEmit": true,
        "esModuleInterop": true,
        "module": "esnext",
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "jsx": "preserve",
        "incremental": true,
        "plugins": [
            {
                "name": "next"
            }
        ],
        // make all imports without a dot '.' prefix relative to the app directory
        "baseUrl": "./app"
    },
    "include": [
        "next-env.d.ts",
        "**/*.ts",
        "**/*.tsx",
        ".next/types/**/*.ts"
    ],
    "exclude": [
        "node_modules"
    ]
}
 

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