Mastering CRUD with NextJS

Mastering CRUD with NextJS

In web development, CRUD operations are fundamental building blocks and crucial for managing data. They are ubiquitous in virtually every application, from simple websites to complex enterprise solutions.

NestJS Boilerplate users have already been able to evaluate and use a powerful new tool - CLI, which allows you to automatically create resources and their properties. With this tool, you can make all CRUD operations and add the necessary fields to them without writing a single line of code manually. Meanwhile, as we have repeatedly announced, the BC Boilerplates ecosystem includes a fully compatible Extensive-React-Boilerplate to provide full functionality (which can be a completely independent solution). Let’s now explore CRUD operations from the frontend perspective.

In Next.js, a React framework with server-side rendering capabilities, these operations can be efficiently managed with features that enhance performance, SEO, and developer experience. Previously, we published an article How to Successfully Launch a Strong NextJS Project, and now we want to go further and analyze the details and nuances of working with the APIs in Next.js.

CRUD operations with examples

As we know, the acronym CRUD stands for Create, Read, Update, and Delete. This concept represents the fundamental operations that can be performed on any data. Let's consider working with CRUD operations using the example of the administrative panel user, where functionalities like adding, editing, and deleting users are implemented, along with retrieving information about them. The custom React hooks discussed below, handling data processing in React Query, pagination, error management, and more, are already integrated into the Extensive-React-Boilerplate. Naturally, you can leverage this boilerplate directly. In the following sections, we’ll share our insights on implementing these features.

Create Operation

Use Case: Submitting data to create a new resource (e.g., user registration, adding a new product).

Implementation: Collect data from the form, send a POST request to the server, handle the response, and update the UI accordingly.

Let’s observe an example. Making a POST request to the API is incorporated creating a new user. In the snippet below the usePostUserService hook is used to encapsulate this logic. We’ve specified the data structure for creating a new user by defining the request and response types but omit this part here to help you focus. You can see more detailed information or a more complete picture in the repository Extensive-React-Boilerplate because this and all the following code snippets are from there.

So, we’ll create a custom hook usePostUserService that, uses the useFetch hook to send a POST request. It takes user data as input and sends it to the API:

function usePostUserService() {

  const fetch = useFetch();

  return useCallback(

    (data: UserPostRequest, requestConfig?: RequestConfigType) => {

      return fetch(`${API_URL}/v1/users`, {

        method: "POST",

        body: JSON.stringify(data),

        ...requestConfig,

      }).then(wrapperFetchJsonResponse<UserPostResponse>);

    },

    [fetch]

  );

}

The function wrapperFetchJsonResponse will be examined later in this article when we get to "error handling".

Read Operations

Use Case: Fetching and displaying a list of resources or a single resource (e.g., fetching user profiles and product lists).

Implementation: Send a GET request to fetch data, handle loading and error states, and render the data in the UI.

In our example, reading data involves making GET requests to the API to fetch user data. It can include fetching all users with pagination, filters, and sorting or fetching a single user by ID after defining the request (UsersRequest) and response types (UsersResponse).

To fetch all users in the custom useGetUsersService hook, we send a GET request with query parameters for pagination, filters, and sorting:

function useGetUsersService() {

  const fetch = useFetch();

  return useCallback(

    (data: UsersRequest, requestConfig?: RequestConfigType) => {

      const requestUrl = new URL(`${API_URL}/v1/users`);

      requestUrl.searchParams.append("page", data.page.toString());

      requestUrl.searchParams.append("limit", data.limit.toString());

      if (data.filters) {

        requestUrl.searchParams.append("filters", JSON.stringify(data.filters));

      }

      if (data.sort) {

        requestUrl.searchParams.append("sort", JSON.stringify(data.sort));

      }

      return fetch(requestUrl, {

        method: "GET",

        ...requestConfig,

      }).then(wrapperFetchJsonResponse<UsersResponse>);

    },

    [fetch]

  );

}

For fetching a Single User the useGetUserService hook sends a GET request to fetch a user by ID:

function useGetUserService() {

  const fetch = useFetch();

  return useCallback(

    (data: UserRequest, requestConfig?: RequestConfigType) => {

      return fetch(`${API_URL}/v1/users/${data.id}`, {

        method: "GET",

        ...requestConfig,

      }).then(wrapperFetchJsonResponse<UserResponse>);

    },

    [fetch]

  );

}

Update Operation

Use Case: Editing an existing resource (e.g., updating user information, editing a blog post).

Implementation: Collect updated data, send a PUT or PATCH request to the server, handle the response, and update the UI.

Let’s carry out updating an existing user, which involves sending a PATCH request to the API with the updated user data. For this, in the custom usePatchUserService hook, we send a PATCH request with the user ID and updated data after defining the request UserPatchRequest and response types UserPatchResponse:

function usePatchUserService() {

  const fetch = useFetch();

  return useCallback(

    (data: UserPatchRequest, requestConfig?: RequestConfigType) => {

      return fetch(`${API_URL}/v1/users/${data.id}`, {

        method: "PATCH",

        body: JSON.stringify(data.data),

        ...requestConfig,

      }).then(wrapperFetchJsonResponse<UserPatchResponse>);

    },

    [fetch]

  );

}

Note: Using PATCH instead of PUT is more advanced for partial data updates, while PUT is typically used for full resource updates.

Delete Operation

Use Case: Removing a resource (e.g., deleting a user or removing an item from a list).

Implementation: Send a DELETE request to the server, handle the response, and update the UI to reflect the removal.

In our next example, deleting a user involves sending a DELETE request to your API with the user ID. After defining the request (UsersDeleteRequest) and response types (UsersDeleteResponse) in the useDeleteUsersService hook, a DELETE request is transmitted to remove the user by ID.

function useDeleteUsersService() {

  const fetch = useFetch();

  return useCallback(

    (data: UsersDeleteRequest, requestConfig?: RequestConfigType) => {

      return fetch(`${API_URL}/v1/users/${data.id}`, {

        method: "DELETE",

        ...requestConfig,

      }).then(wrapperFetchJsonResponse<UsersDeleteResponse>);

    },

    [fetch]

  );

}

These hooks abstract the complexity of making HTTP requests and handling responses. Using such approach ensures a clean and maintainable codebase, as the data-fetching logic is encapsulated and reusable across your components.

Retrieving data in Next.js

Ok, we have dealt with examples of processing CRUD operations, and let's take a closer look at the methods of obtaining data offered by Next.js because it, as a framework, adds its functions and optimizations over React. It is clear that Next.js, beyond CSR (Client-Side Rendering), provides advanced features like SSR (Server-Side Rendering), SSG (Static Site Generation), built-in API routes, and hybrid rendering. So, let's discuss commonalities and differences in retrieving data in Next.js and React.

As soon as React apps are purely client-side, so data fetching happens on the client after the initial page load. For dynamic pages that need to fetch data every time a page is loaded, it is more suitable to use SSR, in this case, data is fetched on the server at the request time.

In the case of SSG, which is suitable for static pages where data doesn’t change often, data is fetched at build time. So, the ‘getStaticProps’ method helps us to fetch data at build time (SSG). If we need pages to be pre-render based on dynamic routes and the data fetched at build time, the ‘getStaticPaths’ method is allowing to do this. It is used in conjunction with the ‘getStaticProps’ to generate dynamic routes at build time. It should be noted that starting with Next 14, we can make requests directly in components without these methods, which gives a more "React experience".

Client-Side Data Fetching with useQuery can be used for interactive components that need to fetch data on the client-side, with initial state hydrated from server-side fetched data. For fetching data that changes frequently or for adding client-side interactivity it is useful the ‘useSWR’ strategy. It’s a React hook for client-side data fetching with caching and revalidation. It allows fetching data on the client side, usually after the initial page load. Nevertheless, it does not fetch data at build time or on the server for SSR, but it can revalidate and fetch new data when required.

To summarize the information about the methods above, we can take a look at the table that provides a comprehensive overview of the different data fetching methods in Next.js, highlighting their respective timings and use cases.

MethodData FetchingTimingUse Case
getStaticPathsStatic Site Generation (SSG)At build timePre-render pages for dynamic routes based on data available at build time.
getStaticPropsStatic Site Generation (SSG)At build timePre-render pages with static content at build time. Ideal for content that doesn't change frequently.
getServerSidePropsServer-Side Rendering (SSR)On each requestFetch data on the server for each request, providing up-to-date content. Ideal for dynamic content that changes frequently.
useQueryClient-Side Rendering (CSR)After the initial page loadFetch initial data server-side, hydrate, reduce redundant network requests, Background Refetching.
useSWRClient-Side Rendering (CSR)After the initial page loadFetch and revalidate data on the client-side, suitable for frequently changing data.

Using React Query with Next.js

React Query provides hooks for fetching, caching, synchronizing, and updating server-state, making it a great tool for handling data in both React and Next.js applications. Key benefits of its use are:

  • Efficient data fetching: It handles caching and background data synchronization, reducing redundant network requests.

  • Automatic refetching: Data can be automatically refetched in the background when it becomes stale, ensuring that the UI always displays the latest information.

  • Integrated error handling: Built-in support for handling errors and retries, making it easier to manage network failures and server errors.

  • Optimistic updates: The ‘useMutation’ hook provides optimistic updates by providing an easy way to handle both the optimistic UI changes and rollback logic if the server request fails.

  • Ease of integration with Next.js: It can be seamlessly integrated with other Next.js data fetching methods like getStaticProps or getServerSideProps (if needed).

  • Inspection of query and mutation: the ReactQueryDevtools tool provides the possibility of viewing the status, data, errors, and other details of all active queries and mutations and watching the query states update in real-time as your application runs.

QueryClientProvider

QueryClientProvider is a context provider component that supplies a QueryClient instance to the React component tree. This instance is necessary for using hooks like useQuery. To set it up, it needs to be placed at the root of your component tree and configure global settings for queries and mutations like retry behavior, cache time, and more. After this, it initializes the React Query client and makes it available throughout the application.

import ReactQueryDevtools from "@/services/react-query/react-query-devtools";

...

export default function RootLayout({

...

}) {

 return (

   <html lang={language} dir={dir(language)}>

     <body>

       <InitColorSchemeScript />

       <QueryClientProvider client={queryClient}>

         <ReactQueryDevtools initialIsOpen={false} />

         …

       </QueryClientProvider>

     </body>

   </html>

 );

}

So, why should it be added to the project? It is beneficial for:

  • Centralized configuration for all queries and mutations.

  • Easy to set up and integrate into existing React applications.

  • Enables features like caching, background refetching, and query invalidation.

React Query Devtools

The other important feature provided by React Query is ReactQueryDevtools - a development tool for inspecting and debugging React Query states. It can be easily added to your application and accessed via a browser extension or as a component like in the example before.

During development, React Query Devtools can be used for inspection of individual queries and mutations, understanding why certain queries are prefetching and monitoring the state of the query cache, and seeing how it evolves over time.

Pagination and Infinite Scrolling

To implement pagination controls or infinite scrolling using features in libraries, useInfiniteQuery is a perfect fit. First, we generate unique keys for caching and retrieving queries in React Query. The by method here creates a unique key based on the sorting and filtering options.

const usersQueryKeys = createQueryKeys(["users"], {

 list: () => ({

   key: [],

   sub: {

     by: ({

       sort,

       filter,

     }: {

       filter: UserFilterType | undefined;

       sort?: UserSortType | undefined;

     }) => ({

       key: [sort, filter],

     }),

   },

 }),

});

To do this, we will use the useInfiniteQuery function from React Query and take the useGetUsersService hook discussed above in the Read Operations section.

export const useUserListQuery = ({

 sort,

 filter,

}: {

 filter?: UserFilterType | undefined;

 sort?: UserSortType | undefined;

} = {}) => {

 const fetch = useGetUsersService();

 const query = useInfiniteQuery({

   queryKey: usersQueryKeys.list().sub.by({ sort, filter }).key,

   initialPageParam: 1,

   queryFn: async ({ pageParam, signal }) => {

     const { status, data } = await fetch(

       {

         page: pageParam,

         limit: 10,

         filters: filter,

         sort: sort ? [sort] : undefined,

       },

       {

         signal,

       }

     );

     if (status === HTTP_CODES_ENUM.OK) {

       return {

         data: data.data,

         nextPage: data.hasNextPage ? pageParam + 1 : undefined,

       };

     }

   },

   getNextPageParam: (lastPage) => {

     return lastPage?.nextPage;

   },

   gcTime: 0,

 });

 return query;

};

QueryFn here retrieves the user data based on the current page, filter, and sort parameters, and the getNextPageParam function determines the next page to fetch based on the response of the last page. When the user scrolls or requests more data, useInfiniteQuery automatically retrieves the next set of data based on the nextPage parameter - this is how infinite scrolling happens. The cache time for the query is set by the gcTime parameter.

Overall, React Query provides a comprehensive solution for managing and debugging server-state in React applications. QueryClientProvider ensures a centralized and consistent configuration for all queries and mutations, while ReactQueryDevtools offers powerful tools for inspecting and understanding query behavior during development.

Error Handling

Implementing CRUD operations always requires proper error handling to ensure user-friendliness and application reliability. Server errors are usually associated with failed processing of a client request, errors in server code, resource overload, infrastructure misconfiguration, or failures in external services. For error handling, Extensive-React-Boilerplate suggests using the wrapperFetchJsonResponse function:

async function wrapperFetchJsonResponse<T>(

 response: Response

): Promise<FetchJsonResponse<T>> {

 const status = response.status as FetchJsonResponse<T>["status"];

 return {

   status,

   data: [

     HTTP_CODES_ENUM.NO_CONTENT,

     HTTP_CODES_ENUM.SERVICE_UNAVAILABLE,

     HTTP_CODES_ENUM.INTERNAL_SERVER_ERROR,

   ].includes(status)

     ? undefined

     : await response.json(),

 };

}

Conclusion

In this article, we covered the fundamental CRUD operations, and explored data retrieval techniques in NextJS. We delved into using React Query to manage state, also outlining the capabilities of QueryClientProvider and ReactQueryDevtools for debugging and optimizing data retrieval. Additionally, we discussed implementing pagination and infinite scrolling to handle large datasets and addressed error handling to make your applications more resilient and ensure a smooth user experience.

By following the examples and techniques outlined in this article, you should now be well-equipped to handle CRUD operations in your NextJS projects. Alternatively, you can use our extensive-react-boilerplate template for your project. It has a fully compatible nestjs-boilerplate backend that implements the ability to work with CRUD operations in minutes, without a single line of code using the CLI, we've covered this in more detail here and here for entity relationships. Keep experimenting, stay updated with best practices, and welcome to try this boilerplate if you find it useful.

Our BC Boilerplates team is always seeking ways to enhance development. We’d love to hear your thoughts on GitHub discussions or in the comments below.

Full credits for this article to Olena Vlasenko and Vlad Shchepotin 🇺🇦