Published: April 20 2021

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:

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

  1. Install Node.js and npm from https://nodejs.org.
  2. Download or clone the Next.js project source code from https://github.com/cornflourblue/next-js-10-crud-example
  3. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  4. 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.
  5. 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:

 

Users Add/Edit Component

Path: /components/users/AddEdit.jsx

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

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

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

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

The form is in "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

Path: /components/Alert.jsx

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)}>&times;</a>
                        <span dangerouslySetInnerHTML={{__html: alert.message}}></span>
                    </div>
                )}
            </div>
        </div>
    );
}
 

Link Component

Path: /components/Link.jsx

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

Path: /components/Nav.jsx

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

import { 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

Path: /components/NavLink.jsx

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

Path: /data/users.json

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

Path: /helpers/fetch-wrapper.js

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

Path: /helpers/users-repo.js

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

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

A dynamic API route handler that handles HTTP requests with any value as the [id] parameter (i.e. /api/users/*). The user id parameter is attached by Next.js to the req.query object 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

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

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

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

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

Path: /pages/users/add.jsx

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

Path: /pages/users/index.jsx

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

Path: /pages/_app.js

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

Path: /pages/index.jsx

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">&gt;&gt; Manage Users</Link></p>
        </div>
    );
}
 

Alert Service

Path: /services/alert.service.js

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 an autoClose boolean property and keepAfterRouteChange boolean property:
    • autoClose - if true tells the alert component to automatically close the alert after three seconds. Default is true.
    • 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 is false.

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

Path: /services/user.service.js

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

Path: /styles/globals.css

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

Path: /config.js

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

Path: /jsconfig.json

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

Path: /next.config.js

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

Path: /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

On Twitter or RSS.


When I'm not coding...

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


Comments


Supported by