Next.js 11 - User Registration and Login Tutorial with Example App
Tutorial built with Next.js 11.1.0
Other versions available:
- Next.js: Next.js 13 (App Router) + MongoDB, Next.js 13 (Pages Router) + MongoDB, Next.js 13 (Pages Router) + MySQL
- 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
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.
Next.js Tutorial Client App
The Next.js client (React) app 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 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.
Data saved to JSON files
To keep the example as simple as possible, instead of using a database (e.g. MySQL, MongoDB, PostgreSQL etc) I'm storing data for users in a JSON flat file located at /data/users.json
, the data is accessed and managed via the users repo which supports all basic CRUD operations.
For instructions on how to connect to MongoDB see Next.js 11 + MongoDB - Connect to Mongo database with Mongoose.
React Hook Form Library
The login form in the example is built with React Hook Form - a relatively new library for working with forms in React using React Hooks, I stumbled across it last year and have been using it in my React and Next.js projects since then, 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-11-registration-login-example.
Here it is in action: (See on CodeSandbox at https://codesandbox.io/s/nextjs-11-user-registration-and-login-example-zde4h)
Run the Next.js Login Tutorial Example Locally
- Install Node.js and npm from https://nodejs.org.
- Download or clone the Next.js project source code from https://github.com/cornflourblue/next-js-11-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 JWT auth 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 Tutorial Project Structure
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
). - data
JSON flat files for storing the Next.js tutorial app data. - 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 with backend apis for the React front-end application, 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 calls, such as displaying and clearing alerts with the alert service. - styles
CSS stylesheets used by the Next.js tutorial app.
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.
The index.js
files in some folders (components, helpers, services) re-export all of the exports from the folder so they can be imported using only the folder path instead of the full path to each file, and to enable importing multiple modules in a single import (e.g. import { errorHandler, jwtMiddleware } from 'helpers/api'
).
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
- Link.jsx
- Nav.jsx
- NavLink.jsx
- Spinner.jsx
- index.js
- account
- data
- helpers
- api
- fetch-wrapper.js
- index.js
- pages
- services
- alert.service.js
- user.service.js
- index.js
- styles
- 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).
The 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('/');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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 "add mode" when there is no user passed in the component props (props.user
), otherwise it is in "edit mode". The variable isAddMode
is used to change the form behaviour based on the mode it is in, for example in "add mode" the password field is required, and in "edit mode" (!isAddMode
) the user details are assigned to the form default values to pre-populate the form when it loads.
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 { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import { Link } from 'components';
import { userService, alertService } from 'services';
export { AddEdit };
function AddEdit(props) {
const user = props?.user;
const isAddMode = !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)
.concat(isAddMode ? Yup.string().required('Password is required') : null)
.min(6, 'Password must be at least 6 characters')
});
const formOptions = { resolver: yupResolver(validationSchema) };
// set default form values if in edit mode
if (!isAddMode) {
formOptions.defaultValues = props.user;
}
// get functions to build form with useForm() hook
const { register, handleSubmit, reset, formState } = useForm(formOptions);
const { errors } = formState;
function onSubmit(data) {
return isAddMode
? createUser(data)
: updateUser(user.id, data);
}
function createUser(data) {
return userService.register(data)
.then(() => {
alertService.success('User added', { keepAfterRouteChange: true });
router.push('.');
})
.catch(alertService.error);
}
function updateUser(id, data) {
return userService.update(id, data)
.then(() => {
alertService.success('User updated', { keepAfterRouteChange: true });
router.push('..');
})
.catch(alertService.error);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-row">
<div className="form-group col">
<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="form-group col">
<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="form-row">
<div className="form-group col">
<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="form-group col">
<label>
Password
{!isAddMode && <em className="ml-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="form-group">
<button type="submit" disabled={formState.isSubmitting} className="btn btn-primary mr-2">
{formState.isSubmitting && <span className="spinner-border spinner-border-sm mr-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 controls the adding & removing of bootstrap alerts in the UI, it maintains an array of alerts
that are rendered in the template returned by the React component.
The useEffect()
hook is used to subscribe to the observable returned from the alertService.onAlert()
method, this enables the alert component to be notified whenever an alert message is sent to the alert service and add it to the alerts
array for display. Sending an alert with an empty message to the alert service tells the alert component to clear the alerts array. The useEffect()
hook is also used to register a route change listener by calling router.events.on('routeChangeStart', clearAlerts);
which automatically clears alerts on route changes.
The empty dependency array []
passed as a second parameter to the useEffect()
hook causes the react hook to only run once when the component mounts, similar to the componentDidMount()
method in a traditional react class component. The function returned from the useEffect()
hook cleans up the subscribtions when the component unmounts, similar to the componentWillUnmount()
method in a traditional react class component.
The removeAlert()
function removes the specified alert
object from the array, it allows individual alerts to be closed in the UI.
The cssClasses()
function returns corresponding bootstrap alert classes for each alert type, if you're using something other than bootstrap you could change the CSS classes returned to suit your application.
The returned JSX template renders a bootstrap alert message for each alert in the alerts array.
For more info see React Hooks + Bootstrap - Alert Notifications.
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import PropTypes from 'prop-types';
import { alertService, AlertType } from 'services';
export { Alert };
Alert.propTypes = {
id: PropTypes.string,
fade: PropTypes.bool
};
Alert.defaultProps = {
id: 'default-alert',
fade: true
};
function Alert({ id, fade }) {
const router = useRouter();
const [alerts, setAlerts] = useState([]);
useEffect(() => {
// subscribe to new alert notifications
const subscription = alertService.onAlert(id)
.subscribe(alert => {
// clear alerts when an empty alert is received
if (!alert.message) {
setAlerts(alerts => {
// filter out alerts without 'keepAfterRouteChange' flag
const filteredAlerts = alerts.filter(x => x.keepAfterRouteChange);
// set 'keepAfterRouteChange' flag to false on the rest
filteredAlerts.forEach(x => delete x.keepAfterRouteChange);
return filteredAlerts;
});
} else {
// add alert to array
setAlerts(alerts => ([...alerts, alert]));
// auto close alert if required
if (alert.autoClose) {
setTimeout(() => removeAlert(alert), 3000);
}
}
});
// clear alerts on location change
const clearAlerts = () => {
setTimeout(() => alertService.clear(id));
};
router.events.on('routeChangeStart', clearAlerts);
// clean up function that runs when the component unmounts
return () => {
// unsubscribe to avoid memory leaks
subscription.unsubscribe();
router.events.off('routeChangeStart', clearAlerts);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function removeAlert(alert) {
if (fade) {
// fade out alert
const alertWithFade = { ...alert, fade: true };
setAlerts(alerts => alerts.map(x => x === alert ? alertWithFade : x));
// remove alert after faded out
setTimeout(() => {
setAlerts(alerts => alerts.filter(x => x !== alertWithFade));
}, 250);
} else {
// remove alert
setAlerts(alerts => alerts.filter(x => x !== alert));
}
};
function cssClasses(alert) {
if (!alert) return;
const classes = ['alert', 'alert-dismissable'];
const alertTypeClass = {
[AlertType.Success]: 'alert-success',
[AlertType.Error]: 'alert-danger',
[AlertType.Info]: 'alert-info',
[AlertType.Warning]: 'alert-warning'
}
classes.push(alertTypeClass[alert.type]);
if (alert.fade) {
classes.push('fade');
}
return classes.join(' ');
}
if (!alerts.length) return null;
return (
<div className="container">
<div className="m-3">
{alerts.map((alert, index) =>
<div key={index} className={cssClasses(alert)}>
<a className="close" onClick={() => removeAlert(alert)}>×</a>
<span dangerouslySetInnerHTML={{ __html: alert.message }}></span>
</div>
)}
</div>
</div>
);
}
Link Component
A custom link component that wraps the Next.js link component to make it work more like the standard link component from React Router.
The built-in Next.js link component accepts an href
attribute but requires an <a>
tag to be nested inside it to work. Attributes other than href (e.g. className
) must be added to the <a>
tag. For more info on the Next.js link component see https://nextjs.org/docs/api-reference/next/link.
This custom link component accepts href
, className
plus any other props, and doesn't require any nested <a>
tag (e.g. <Link href="/" className="my-class">Home</Link>).
import NextLink from 'next/link';
export { Link };
function Link({ href, children, ...props }) {
return (
<NextLink href={href}>
<a {...props}>
{children}
</a>
</NextLink>
);
}
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();
}, []);
function logout() {
userService.logout();
}
// only show nav when logged in
if (!user) return null;
return (
<nav className="navbar navbar-expand navbar-dark bg-dark">
<div className="navbar-nav">
<NavLink href="/" exact className="nav-item nav-link">Home</NavLink>
<NavLink href="/users" className="nav-item nav-link">Users</NavLink>
<a onClick={logout} className="nav-item nav-link">Logout</a>
</div>
</nav>
);
}
NavLink Component
An extended version of the custom 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 PropTypes from 'prop-types';
import { Link } from '.';
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-3">
<span className="spinner-border spinner-border-lg align-center"></span>
</div>
);
}
Users JSON Data File
A JSON file containing user data for the Next.js tutorial app, the data is accessed and managed via the users repo which supports all basic CRUD operations. The file contains an empty array ([]
) by default which is first populated when a new user is registered.
I decided to use a JSON file to store data instead of a database (e.g. MySQL, MongoDB, PostgreSQL etc) to keep the tutorial simple and focused on how to implement user registration and login functionality in Next.js.
[]
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);
}
}
}
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 a 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 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.
const expressJwt = require('express-jwt');
const util = require('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);
}
Omit Function
The omit()
helper function is used to omit/exclude a key
from an object (obj
). It's used in the tutorial app to omit the password hash
property from users returned by the api (e.g. users index handler, users id handler).
The lodash
library contains an omit function as well, but I decided to write my own since it's a tiny function and would've felt like overkill to add a whole library for it.
export { omit };
function omit(obj, key) {
const { [key]: omitted, ...rest } = obj;
return rest;
}
Users Repo
The users repo encapsulates all access to user data stored in the users JSON data file and exposes a standard set of CRUD methods for reading and managing the data. It's used on the server-side by the Next.js users API route handlers (authenticate.js, register.js, [id].js, index.js).
Data is stored in a JSON file for simplicity to keep the tutorial simple and focused on how to implement user registration and login functionality in Next.js. A real database (e.g. MySQL, MongoDB, PostgreSQL etc) is recommended for production applications.
const fs = require('fs');
// users in JSON file for simplicity, store in a db for production applications
let users = require('data/users.json');
export const usersRepo = {
getAll: () => users,
getById: id => users.find(x => x.id.toString() === id.toString()),
find: x => users.find(x),
create,
update,
delete: _delete
};
function create(user) {
// generate new user id
user.id = users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
// set date created and updated
user.dateCreated = new Date().toISOString();
user.dateUpdated = new Date().toISOString();
// add and save user
users.push(user);
saveData();
}
function update(id, params) {
const user = users.find(x => x.id.toString() === id.toString());
// set date updated
user.dateUpdated = new Date().toISOString();
// update and save
Object.assign(user, params);
saveData();
}
// prefixed with underscore '_' because 'delete' is a reserved word in javascript
function _delete(id) {
// filter out deleted user and save
users = users.filter(x => x.id.toString() !== id.toString());
saveData();
}
// private helper functions
function saveData() {
fs.writeFileSync('data/users.json', JSON.stringify(users, null, 4));
}
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,
post,
put,
delete: _delete
};
function get(url) {
const requestOptions = {
method: 'GET',
headers: authHeader(url)
};
return fetch(url, requestOptions).then(handleResponse);
}
function post(url, body) {
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader(url) },
credentials: 'include',
body: JSON.stringify(body)
};
return fetch(url, requestOptions).then(handleResponse);
}
function put(url, body) {
const requestOptions = {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeader(url) },
body: JSON.stringify(body)
};
return fetch(url, requestOptions).then(handleResponse);
}
// prefixed with underscored because delete is a reserved word in javascript
function _delete(url) {
const requestOptions = {
method: 'DELETE',
headers: authHeader(url)
};
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 && user.token;
const isApiUrl = url.startsWith(publicRuntimeConfig.apiUrl);
if (isLoggedIn && isApiUrl) {
return { Authorization: `Bearer ${user.token}` };
} else {
return {};
}
}
function handleResponse(response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
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();
}
const error = (data && data.message) || response.statusText;
return Promise.reject(error);
}
return data;
});
}
Next.js Login Page
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 { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import { Link } from 'components';
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 }) {
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="form-group">
<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="form-group">
<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 mr-1"></span>}
Login
</button>
<Link href="/account/register" className="btn btn-link">Register</Link>
</form>
</div>
</div>
</Layout>
);
}
Next.js Register Page
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 { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import { Link } from 'components';
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', { keepAfterRouteChange: 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="form-group">
<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="form-group">
<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="form-group">
<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="form-group">
<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 mr-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 and 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() function which map to the functions getById()
, update()
and _delete()
.
const bcrypt = require('bcryptjs');
import { apiHandler } from 'helpers/api';
import { usersRepo, omit } from 'helpers/api';
export default apiHandler({
get: getById,
put: update,
delete: _delete
});
function getById(req, res) {
const user = usersRepo.getById(req.query.id);
if (!user) throw 'User Not Found';
return res.status(200).json(omit(user, 'hash'));
}
function update(req, res) {
const user = usersRepo.getById(req.query.id);
if (!user) throw 'User Not Found';
// split out password from user details
const { password, ...params } = req.body;
// validate
if (user.username !== params.username && usersRepo.find(x => x.username === params.username))
throw `User with the username "${params.username}" already exists`;
// only update hashed password if entered
if (password) {
user.hash = bcrypt.hashSync(password, 10);
}
usersRepo.update(req.query.id, params);
return res.status(200).json({});
}
function _delete(req, res) {
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() function.
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.
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
import getConfig from 'next/config';
import { apiHandler, usersRepo } from 'helpers/api';
const { serverRuntimeConfig } = getConfig();
export default apiHandler({
post: authenticate
});
function authenticate(req, res) {
const { username, password } = req.body;
const user = usersRepo.find(u => u.username === username);
// validate
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 basic user details and token
return res.status(200).json({
id: user.id,
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
token
});
}
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 which are mapped to the getUsers()
function, which returns all users without their password hash
property. Security for this and all other secure routes in the API is handled by the global JWT middleware.
The route handler supports HTTP GET
requests by passing an object with a get()
method to the apiHandler() function.
import { apiHandler, usersRepo, omit } from 'helpers/api';
export default apiHandler({
get: getUsers
});
function getUsers(req, res) {
// return users without hashed passwords in the response
const response = usersRepo.getAll().map(x => omit(x, 'hash'));
return res.status(200).json(response);
}
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.
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.
const bcrypt = require('bcryptjs');
import { apiHandler, usersRepo } from 'helpers/api';
export default apiHandler({
post: register
});
function register(req, res) {
// split out password from user details
const { password, ...user } = req.body;
// validate
if (usersRepo.find(x => x.username === user.username))
throw `User with the username "${user.username}" already exists`;
// hash password
user.hash = bcrypt.hashSync(password, 10);
usersRepo.create(user);
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 it 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 passed to the component by the getServerSideProps()
function on page load.
import { useState, useEffect } from 'react';
import { Layout, AddEdit } from 'components/users';
import { Spinner } from 'components';
import { userService, alertService } from 'services';
export default Edit;
function Edit({ id }) {
const [user, setUser] = useState(null);
useEffect(() => {
// fetch user and set default form values if in edit mode
userService.getById(id)
.then(x => setUser(x))
.catch(alertService.error)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Layout>
<h1>Edit User</h1>
{user ? <AddEdit user={user} /> : <Spinner /> }
</Layout>
);
}
export async function getServerSideProps({ params }) {
return {
props: { id: params.id }
}
}
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 { useState, useEffect } from 'react';
import { Link, 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 mr-1">Edit</Link>
<button onClick={() => deleteUser(user.id)} className="btn btn-sm btn-danger btn-delete-user" 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 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 and add the bootstrap css stylesheet. 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);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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 11 - User Registration and Login Example</title>
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link href="//netdna.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
</Head>
<div className={`app-container ${user ? 'bg-light' : ''}`}>
<Nav />
<Alert />
{authorized &&
<Component {...pageProps} />
}
</div>
</>
);
}
Home Page
The home page is a basic react function component that displays a welcome message to the logged in user and a link to the users section.
import { userService } from 'services';
import { Link } from 'components';
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, clearing and subscribing to alerts.
The AlertType
object defines the types of alerts supported by the login tutorial app.
You can trigger alert notifications from any component in the application by calling one of the convenience methods for displaying different types of alerts: success()
, error()
, info()
and warn()
.
Alert convenience method parameters
- The first parameter is the alert
message
string, which can be plain text or HTML. - The second parameter is an optional
options
object that supports anautoClose
boolean property andkeepAfterRouteChange
boolean property:autoClose
- if true tells the alert component to automatically close the alert after three seconds. Default istrue
.keepAfterRouteChange
- if true prevents the alert from being closed after one route change, this is handy for displaying messages after a redirect such as when a user is created or updated. Default isfalse
.
For more info see React Hooks + Bootstrap - Alert Notifications.
import { Subject } from 'rxjs';
import { filter } from 'rxjs/operators';
export const alertService = {
onAlert,
success,
error,
info,
warn,
alert,
clear
};
export const AlertType = {
Success: 'Success',
Error: 'Error',
Info: 'Info',
Warning: 'Warning'
};
const alertSubject = new Subject();
const defaultId = 'default-alert';
// enable subscribing to alerts observable
function onAlert(id = defaultId) {
return alertSubject.asObservable().pipe(filter(x => x && x.id === id));
}
// convenience methods
function success(message, options) {
alert({ ...options, type: AlertType.Success, message });
}
function error(message, options) {
alert({ ...options, type: AlertType.Error, message });
}
function info(message, options) {
alert({ ...options, type: AlertType.Info, message });
}
function warn(message, options) {
alert({ ...options, type: AlertType.Warning, message });
}
// core alert method
function alert(alert) {
alert.id = alert.id || defaultId;
alert.autoClose = (alert.autoClose === undefined ? true : alert.autoClose);
alertSubject.next(alert);
}
// clear alerts
function clear(id = defaultId) {
alertSubject.next({ id });
}
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 + 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';
const { publicRuntimeConfig } = getConfig();
const baseUrl = `${publicRuntimeConfig.apiUrl}/users`;
const userSubject = new BehaviorSubject(process.browser && JSON.parse(localStorage.getItem('user')));
export const userService = {
user: userSubject.asObservable(),
get userValue () { return userSubject.value },
login,
logout,
register,
getAll,
getById,
update,
delete: _delete
};
function login(username, password) {
return fetchWrapper.post(`${baseUrl}/authenticate`, { username, password })
.then(user => {
// 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));
return user;
});
}
function logout() {
// 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');
}
function register(user) {
return fetchWrapper.post(`${baseUrl}/register`, user);
}
function getAll() {
return fetchWrapper.get(baseUrl);
}
function getById(id) {
return fetchWrapper.get(`${baseUrl}/${id}`);
}
function update(id, params) {
return fetchWrapper.put(`${baseUrl}/${id}`, params)
.then(x => {
// 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);
}
return x;
});
}
// prefixed with underscored because delete is a reserved word in javascript
function _delete(id) {
return fetchWrapper.delete(`${baseUrl}/${id}`);
}
Global CSS Styles
The globals.css file contains global custom CSS styles for the example JWT auth app. It's imported into the tutorial app by the Next.js app component.
a { cursor: pointer; }
.app-container {
min-height: 350px;
}
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/).
module.exports = {
reactStrictMode: true,
serverRuntimeConfig: {
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
}
}
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": "^2.7.1",
"bcryptjs": "^2.4.3",
"express-jwt": "^6.1.0",
"jsonwebtoken": "^8.5.1",
"next": "^11.1.0",
"prop-types": "^15.7.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.12.1",
"rxjs": "^7.3.0",
"yup": "^0.32.9"
},
"devDependencies": {
"eslint": "7.32.0",
"eslint-config-next": "11.1.0"
}
}
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!