Next.js 10 - CRUD Example with React Hook Form
Tutorial built with Next.js 10.1.3 and React Hook Form 7.0.5
Other versions available:
- React: React Hook Form, Formik
- Angular: Angular 14, 11, 10
This tutorial shows how to build a basic Next.js CRUD application with the React Hook Form library that includes pages that list, add, edit and delete records using Next.js API routes. The records in the example app are user records, but the same CRUD pattern and code structure can be used to manage any type of data e.g. products, services, articles etc.
Data saved to JSON files
To keep the example simple, instead of using a database (e.g. MySQL, MongoDB, PostgreSQL etc) I'm reading and writing data for users to 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.
React Hook Form Library
React Hook Form is a relatively new library for working with forms in React using React Hooks, I stumbled across it about six months ago 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.
Example CRUD App Overview
The example app includes a basic home page and users section with CRUD functionality, the default page in the users section displays a list of all users and includes buttons to add, edit and delete users. The add and edit buttons navigate to a page containing a React Hook Form for creating or updating a user record, and the delete button executes a function within the user list component to delete the user record. The add and edit forms are both implemented with the same add/edit component which behaves differently depending on which mode it is in ("add mode" vs "edit mode").
The example project is available on GitHub at https://github.com/cornflourblue/next-js-10-crud-example.
Here it is in action: (See on CodeSandbox at https://codesandbox.io/s/nextjs-10-crud-example-with-react-hook-form-cgkg2)
Run the Next.js CRUD 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-10-crud-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 CRUD 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.
Next.js 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/users
). - data
JSON flat files for storing the example CRUD app data. - helpers
Anything that doesn't fit into the other folders and doesn't justify having its own folder. - pages
Pages and api endpoints for the Next.js CRUD 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 endpoints 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 example CRUD 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 { userService, alertService } from 'services'
).
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
- users
- AddEdit.jsx
- index.js
- Alert.jsx
- Link.jsx
- Nav.jsx
- NavLink.jsx
- index.ts
- users
- data
- helpers
- fetch-wrapper.js
- users-repo.js
- index.js
- pages
- services
- alert.service.js
- user.service.js
- index.js
- styles
- config.js
- jsconfig.json
- next.config.js
- package.json
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 (except the password fields) 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('title')}
). For more info on form validation with React Hook Form see React Hook Form 7 - Form Validation Example.
import { useRouter } from 'next/router';
import { useState } from 'react';
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();
const [showPassword, setShowPassword] = useState(false);
// form validation rules
const validationSchema = Yup.object().shape({
title: Yup.string()
.required('Title is required'),
firstName: Yup.string()
.required('First Name is required'),
lastName: Yup.string()
.required('Last Name is required'),
email: Yup.string()
.email('Email is invalid')
.required('Email is required'),
role: Yup.string()
.required('Role 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'),
confirmPassword: Yup.string()
.transform(x => x === '' ? undefined : x)
.when('password', (password, schema) => {
if (password || isAddMode) return schema.required('Confirm Password is required');
})
.oneOf([Yup.ref('password')], 'Passwords must match')
});
const formOptions = { resolver: yupResolver(validationSchema) };
// set default form values if user passed in props
if (!isAddMode) {
const { password, confirmPassword, ...defaultValues } = user;
formOptions.defaultValues = defaultValues;
}
// 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.create(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)}>
<h1>{isAddMode ? 'Add User' : 'Edit User'}</h1>
<div className="form-row">
<div className="form-group col">
<label>Title</label>
<select name="title" {...register('title')} className={`form-control ${errors.title ? 'is-invalid' : ''}`}>
<option value=""></option>
<option value="Mr">Mr</option>
<option value="Mrs">Mrs</option>
<option value="Miss">Miss</option>
<option value="Ms">Ms</option>
</select>
<div className="invalid-feedback">{errors.title?.message}</div>
</div>
<div className="form-group col-5">
<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-5">
<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-7">
<label>Email</label>
<input name="email" type="text" {...register('email')} className={`form-control ${errors.email ? 'is-invalid' : ''}`} />
<div className="invalid-feedback">{errors.email?.message}</div>
</div>
<div className="form-group col">
<label>Role</label>
<select name="role" {...register('role')} className={`form-control ${errors.role ? 'is-invalid' : ''}`}>
<option value=""></option>
<option value="User">User</option>
<option value="Admin">Admin</option>
</select>
<div className="invalid-feedback">{errors.role?.message}</div>
</div>
</div>
{!isAddMode &&
<div>
<h3 className="pt-3">Change Password</h3>
<p>Leave blank to keep the same password</p>
</div>
}
<div className="form-row">
<div className="form-group col">
<label>
Password
{!isAddMode &&
(!showPassword
? <span> - <a onClick={() => setShowPassword(!showPassword)} className="text-primary">Show</a></span>
: <em> - {user.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 className="form-group col">
<label>Confirm Password</label>
<input name="confirmPassword" type="password" {...register('confirmPassword')} className={`form-control ${errors.confirmPassword ? 'is-invalid' : ''}`} />
<div className="invalid-feedback">{errors.confirmPassword?.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>
);
}
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', onRouteChange);
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);
// remove 'keepAfterRouteChange' flag 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 onRouteChange = () => alertService.clear(id);
router.events.on('routeChangeStart', onRouteChange);
// clean up function that runs when the component unmounts
return () => {
// unsubscribe to avoid memory leaks
subscription.unsubscribe();
router.events.off('routeChangeStart', onRouteChange);
};
}, []);
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 { NavLink } from '.';
export { Nav };
function Nav() {
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>
</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>;
}
Users JSON Data File
A JSON file containing user data for the example app, the data is accessed and managed via the users repo which supports all basic CRUD operations.
I decided to use files to store data instead of a full database (e.g. MySQL, MongoDB, PostgreSQL etc) to keep the example simple and focused on the implementation of CRUD operations in Next.js.
[
{
"title": "Mr",
"firstName": "Frank",
"lastName": "Murphy",
"email": "[email protected]",
"role": "User",
"password": "sue123",
"id": 1,
"dateCreated": "2021-04-08T05:33:05.184Z",
"dateUpdated": "2021-04-08T05:33:05.184Z"
}
]
Fetch Wrapper
The fetch wrapper is a lightweight wrapper around the native browser and node fetch()
functions 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
).
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.
export const fetchWrapper = {
get,
post,
put,
delete: _delete
};
function get(url) {
const requestOptions = {
method: 'GET'
};
return fetch(url, requestOptions).then(handleResponse);
}
function post(url, body) {
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
};
return fetch(url, requestOptions).then(handleResponse);
}
function put(url, body) {
const requestOptions = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
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'
};
return fetch(url, requestOptions).then(handleResponse);
}
// helper functions
function handleResponse(response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
const error = (data && data.message) || response.statusText;
return Promise.reject(error);
}
return data;
});
}
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 id and users index API route handlers.
const fs = require('fs');
let users = require('data/users.json');
export const usersRepo = {
getAll,
getById,
create,
update,
delete: _delete
};
function getAll() {
return users;
}
function getById(id) {
return users.find(x => x.id.toString() === id.toString());
}
function create({ title, firstName, lastName, email, role, password }) {
const user = { title, firstName, lastName, email, role, password };
// validate
if (users.find(x => x.email === user.email))
throw `User with the email ${user.email} already exists`;
// 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, { title, firstName, lastName, email, role, password }) {
const params = { title, firstName, lastName, email, role, password };
const user = users.find(x => x.id.toString() === id.toString());
// validate
if (params.email !== user.email && users.find(x => x.email === params.email))
throw `User with the email ${params.email} already exists`;
// only update password if entered
if (!params.password) {
delete params.password;
}
// 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));
}
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 which map to associated CRUD functions getUserById()
, updateUser()
and deleteUser()
.
import { usersRepo } from 'helpers';
export default handler;
function handler(req, res) {
switch (req.method) {
case 'GET':
return getUserById();
case 'PUT':
return updateUser();
case 'DELETE':
return deleteUser();
default:
return res.status(405).end(`Method ${req.method} Not Allowed`)
}
function getUserById() {
const user = usersRepo.getById(req.query.id);
return res.status(200).json(user);
}
function updateUser() {
try {
usersRepo.update(req.query.id, req.body);
return res.status(200).json({});
} catch (error) {
return res.status(400).json({ message: error });
}
}
function deleteUser() {
usersRepo.delete(req.query.id);
return res.status(200).json({});
}
}
Users Index API Route Handler
A Next.js API route handler that handles HTTP requests to the default users route /api/users
.
The route handler supports HTTP GET
and POST
requests which map to associated CRUD functions getUsers()
and createUser()
.
import { usersRepo } from 'helpers';
export default handler;
function handler(req, res) {
switch (req.method) {
case 'GET':
return getUsers();
case 'POST':
return createUser();
default:
return res.status(405).end(`Method ${req.method} Not Allowed`)
}
function getUsers() {
const users = usersRepo.getAll();
return res.status(200).json(users);
}
function createUser() {
try {
usersRepo.create(req.body);
return res.status(200).json({});
} catch (error) {
return res.status(400).json({ message: error });
}
}
}
Edit User Page
The edit user page is a thin wrapper around the add/edit user component that re-exports the component and fetches the specified user in the getServerSideProps()
function to set the component to "edit" mode. The function fetches the user with the id parameter from the URL (params.id
), and the props
object returned by getServerSideProps()
is passed to the add/edit user component on page load.
import { AddEdit } from 'components/users';
import { userService } from 'services';
export default AddEdit;
export async function getServerSideProps({ params }) {
const user = await userService.getById(params.id);
return {
props: { user }
}
}
Add User Page
The add user page simply re-exports the add/edit user component without any user
specified so the component is set to "add" mode.
import { AddEdit } from 'components/users';
export default AddEdit;
Users Index Page
The users index page displays a list of all users 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 and then removes the deleted user from local state to remove it from the UI.
import { useState, useEffect } from 'react';
import { Link } from 'components';
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 (
<div>
<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%' }}>Name</th>
<th style={{ width: '30%' }}>Email</th>
<th style={{ width: '30%' }}>Role</th>
<th style={{ width: '10%' }}></th>
</tr>
</thead>
<tbody>
{users && users.map(user =>
<tr key={user.id}>
<td>{user.title} {user.firstName} {user.lastName}</td>
<td>{user.email}</td>
<td>{user.role}</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" className="text-center">
<div className="spinner-border spinner-border-lg align-center"></div>
</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>
</div>
);
}
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.
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 'styles/globals.css';
import { Nav, Alert } from 'components';
export default App;
function App({ Component, pageProps }) {
return (
<>
<Head>
<title>Next.js 10 - CRUD Example with React Hook Form</title>
{/* bootstrap css */}
<link href="//netdna.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
</Head>
<div className="app-container bg-light">
<Nav />
<Alert />
<div className="container pt-4 pb-4">
<Component {...pageProps} />
</div>
</div>
</>
);
}
Home Page
The home page is a basic react function component that displays some HTML and a link to the users page.
import { Link } from 'components';
export default Home;
function Home() {
return (
<div>
<h1>Next.js 10 - CRUD Example with React Hook Form</h1>
<p>An example app showing how to list, add, edit and delete user records with Next.js 10 and the React Hook Form library.</p>
<p><Link href="/users">>> Manage Users</Link></p>
</div>
);
}
Alert Service
The alert service acts as the bridge between any component in a Next.js or React application and the alert component that displays the notification. It contains methods for sending, clearing and subscribing to alerts.
The AlertType
object defines the types of alerts supported by the example CRUD 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 from the React front-end of the Next.js app to the backend API, it contains standard CRUD methods for managing users that make corresponding HTTP requests to the /users
routes of the API with the fetch wrapper.
import { apiUrl } from 'config';
import { fetchWrapper } from 'helpers';
export const userService = {
getAll,
getById,
create,
update,
delete: _delete
};
const baseUrl = `${apiUrl}/users`;
function getAll() {
return fetchWrapper.get(baseUrl);
}
function getById(id) {
return fetchWrapper.get(`${baseUrl}/${id}`);
}
function create(params) {
return fetchWrapper.post(baseUrl, params);
}
function update(id, params) {
return fetchWrapper.put(`${baseUrl}/${id}`, params);
}
// 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 CRUD app. It's imported into the application by the Next.js app component.
a { cursor: pointer; }
.app-container {
min-height: 350px;
}
.btn-delete-user {
width: 40px;
text-align: center;
box-sizing: content-box;
}
App Config
The app config file defines global config variables that are available to components in the Next.js app. It supports setting different values for variables based on environment (e.g. development vs production).
This example config file contains an apiUrl
used by the user service.
const apiUrl = process.env.NODE_ENV === 'development'
? 'http://localhost:3000/api' // development api
: 'http://localhost:3000/api'; // production api
export {
apiUrl
};
JavaScript Config
The jsconfig baseUrl
option is used to configure absolute imports for the Next.js 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 is used in the example to customise the webpack config to fix a build error.
The example app saves data to JSON files using the Node.js file system module (fs
) in the users repo. This can cause an error during the Next.js build phase because the fs
module only exists on the server-side and not on the browser, so to fix the error you can configure webpack to set fs
to an empty module on the client (!isServer
).
For more info on adding custom webpack config to Next.js see https://nextjs.org/docs/api-reference/next.config.js/custom-webpack-config.
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
// set 'fs' to an empty module on the client to prevent this error on build --> Error: Can't resolve 'fs'
config.node = {
fs: 'empty'
}
}
return config;
}
}
Package.json
The package.json file contains project configuration information including scripts for running and building the Next.js 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-crud-example",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@hookform/resolvers": "^2.0.0",
"next": "^10.1.3",
"prop-types": "^15.7.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.0.4",
"rxjs": "^6.6.7",
"yup": "^0.32.9"
}
}
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!