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 app overview
- Tools required to run the Next.js + MongoDB app
- Run the Next.js example app locally
- Next.js + MongoDB code documentation
- Other versions of this tutorial
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 specifiedid
./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:
- Node.js and npm - includes the Node runtime and command line tools.
- Visual Studio Code - code editor that runs on Windows, Mac and Linux. If you have a different preferred code editor that's fine too.
- MongoDB - you'll need access to a running MongoDB server instance for the Next.js app to connect to, it can be remote (e.g. Azure, AWS etc) or on your local machine. The MongoDB Community Server downloads page is at https://www.mongodb.com/download-center/community. You can also run it in a Docker container, the official docker images for MongoDB are available at https://hub.docker.com/_/mongo.
- Ensure MongoDB is started so the app can connect to it - instructions are available on the install page for each OS at https://docs.mongodb.com/manual/administration/install-community/.
Run the Next.js + MongoDB Login Example Locally
Follow these steps to download and run the Next.js 13 + MongoDB Auth App:
- Download or clone the Next.js project source code from https://github.com/cornflourblue/next-js-13-mongodb-registration-login-example
- Install all required npm packages by running
npm install
ornpm i
from the command line in the project root folder (where the package.json is located). - 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. - 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:
- components
- account
- Layout.jsx
- index.js
- users
- AddEdit.jsx
- Layout.jsx
- index.js
- Alert.jsx
- Nav.jsx
- NavLink.jsx
- Spinner.jsx
- index.js
- account
- helpers
- api
- fetch-wrapper.js
- index.js
- pages
- account
- api
- users
- _app.js
- _document.js
- index.jsx
- services
- alert.service.js
- user.service.js
- index.js
- styles
- .eslintrc.json
- jsconfig.json
- next.config.js
- package.json
Account Layout Component
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 isfalse
.
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
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
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
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
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
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
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:
- Next.js: Next.js 13 (App Router) + MongoDB, Next.js 13 (Pages Router) + MySQL, Next.js 11
- React: React 18 + Redux, React 17 + Recoil, React 16 Hooks + Redux, React 16 + Redux
- Angular: Angular 15/16, 14, 10, 9, 8, 7, 6, 2/5
- Vue: Vue 3 + Pinia, Vue 2 + Vuex
- AngularJS: AngularJS
- .NET: Blazor WebAssembly
Need Some NextJS Help?
Search fiverr for freelance NextJS developers.
Follow me for updates
When I'm not coding...
Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!