DEV Community

Faris Aziz
Faris Aziz

Posted on • Edited on

Resilient and Performant Data Fetching in Next.js

A tutorial on how to use the best of React Query and Next.js to optimize API calls and render data to a view

APIs are usually our primary data source for driving our website’s views. They are a core part of how our application presents information, such as the weather, user data, lists of transactions, you name it! Given the importance of fetching data and rendering it, we must ensure we do this in a performant and resilient manner. Server errors, network issues, request timeouts, and other factors could prevent your data from populating your views. Luckily, there are a few tools we can use to optimise our initial requests and continuously update data when it becomes stale.

Next.js Server-Side Fetching

What’s better than showing a well-designed loading spinner while you fetch data? Not needing one at all!

Next.js offers a page-level function called getServerSideProps, which allows us to perform a NodeJS fetch request and pass it as props to the page. This gives us the benefit of having data ready on our client side.

Fetching and rendering a list of companies from the Faker API may look like this:

const fetchCompanies = async () => {
  const res = await fetch("https://fakerapi.it/api/v1/companies");

  if (!res.ok) {
    throw new Error("Something went wrong");
  }

  const { data = [] } = await res.json();

  return data;
};

export default function Home({ data }) {
  return (
    <div>
      <h1>Companies</h1>
      <ol>
        {data && data.map(({ id, name, email, vat, phone, website }) => (
          <li key={id}>
            <h2>{name}</h2>
            <p>{email}</p>
            <p>{vat}</p>
            <p>{phone}</p>
            <p>{website}</p>
          </li>
        ))}
      </ol>
    </div>
  );
};

export const getServerSideProps = async () => {
  const data = await fetchCompanies()
  return {
    props: {
      data
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

Page View

Image description

Looks great, right? The list renders almost instantly, and we don’t even need to show a loading state (for now).

If we lived in a perfect world, this might be enough to ship to production. However, in a realistic scenario, we have to account for initial requests failing, needing to retry, caching for performance, and avoiding hitting the API limit.

React Query to the Rescue

React Query is an excellent data fetch library that allows us to use initial server-side fetches, retries, caching, request timeouts, and more!

The first improvement we could make to the above code is introducing a query hydration and retry mechanism. Hydration is the process of using client-side JavaScript to add application state and interactivity to server-rendered HTML.

To get started, we need to wrap our pages in React Query’s providers inside _app.js.

import { QueryClient, QueryClientProvider, Hydrate } from "@tanstack/react-query";
// Create a client
const queryClient = new QueryClient();
export default function MyApp({ Component, pageProps }) {
  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </Hydrate>
    </QueryClientProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Two things are happening here. First, we’re wrapping our app with a QueryClientProvider, which gives React Query’s hooks access to an instance of QueryClient via context. Secondly, we are passing a dehydrated state to Hydrate. The dehydrated state, which will come from our server-side fetch, is a frozen representation of a cache that can later be hydrated on the client side.

Revising our Initial Approach

import { QueryClient, dehydrate } from "@tanstack/react-query";

const fetchCompanies = async () => {
  const res = await fetch("https://fakerapi.it/api/v1/companies");

  if (!res.ok) {
    throw new Error("Something went wrong");
  }

  const { data = [] } = await res.json();

  return data;
};

export default function Home() {
  const { data, error, isLoading } = useQuery({ queryKey: 'companies', queryFn: fetchCompanies, staleTime: 60_000 }); // stale after 1 min
  if (isLoading){
   return <h1>Loading...</h1>
  }
  if (error){
   return <h1>{error.message}</h1>
  }
  return (
    <div>
      <h1>Companies</h1>
      <ol>
        {data && data.map(({ id, name, email, vat, phone, website }) => (
          <li key={id}>
            <h2>{name}</h2>
            <p>{email}</p>
            <p>{vat}</p>
            <p>{phone}</p>
            <p>{website}</p>
          </li>
        ))}
      </ol>
    </div>
  );
};

export const getServerSideProps = async () => {
  const queryClient = new QueryClient()
  await queryClient.prefetchQuery(["companies"], fetchCompanies);

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

What has changed?

Image description

If we look at our preliminary page load, we’ve still got our rendered list of companies and no sign of any client-side fetch requests. This means we’re still fetching server-side initially.

Our benefit is that our data will refresh client-side when it goes stale. Data could go state due to our cache expiring, us indicating a timeout for fresh data, or performing another request (e.g., creating a new data entry with a POST request), which triggers an invalidation of the current data.

Image description

Rechecking the network tab, we can see that a client-side fetch is triggered as soon as the data is considered stale. As a result, React Query will attempt to recover data multiple times if the initial request fails.

Conclusion (almost)

That’s it! We’ve made our application performant and reactive towards request failures. Additionally, React Query can be configured to further optimize application state and requests, depending on the type of application you’re developing.

Having said that, there is one more thing we can do…

Refactoring

Let’s clean up this code and turn it into something much nicer.

We can start by defining an enum for our keys and a function mapping for our fetchers.

const QUERY_KEYS = {
  COMPANIES: "companies",
}
const queryFunctions = {
  [QUERY_KEYS.COMPANIES]: fetchCompanies,
};
Enter fullscreen mode Exit fullscreen mode

After that, we can take our useQuery hook and create a HOC (higher-order component) with it.

export const withQuery = (Component, key) => {
  return (props) => {
    const queryResponse = useQuery({ queryKey: [key], queryFn: queryFunctions[key], staleTime: 50_000 });
    return <Component {...{...props, ...queryResponse}} />;
  };
};
Enter fullscreen mode Exit fullscreen mode

This HOC will wrap our page component and spread the query response props (along with page props) for us, similar to how we used the data we received directly from getServerSideProps in our first iteration. This gives us the benefit of having a simple interface and abstracting the implementation details.

Our final page component will look like this:

const Home = ({ data, error, isLoading }) => {

  if (isLoading) {
    return <h1>Loading...</h1>;
  }

  if (error) {
    return <h1>{error.message}</h1>;
  }

  return (
    <div>
      <ReactQueryDevtools initialIsOpen={false} />
      <h1>Companies</h1>
      <ol>
        {data && data.map(({ id, name, email, vat, phone, website }) => (
          <li key={id}>
            <h2>{name}</h2>
            <p>{email}</p>
            <p>{vat}</p>
            <p>{phone}</p>
            <p>{website}</p>
          </li>
        ))}
      </ol>
    </div>
  );
};


export default withQuery(Home, QUERY_KEYS.COMPANIES);

Enter fullscreen mode Exit fullscreen mode

We could also, of course, extend this to use multiple queries, pass additional configurations to useQuery, etc., but for now, this will suffice.

I hope this gives you some insight into how to increase the resilience and performance of your API requests inside Next.js.

Happy fetching! 🔥

Top comments (0)