Over 2,000 mentors available, including leaders at Amazon, Airbnb, Netflix, and more. Check it out
Published

Resilient and Performant Data Fetching in NextJS

A tutorial on how to use the best of React Query and NextJS to optimise API calls and rendering data to a view
Faris Aziz

Engineering Manager, Navro (formerly Paytrix)

APIs are usually our primary source of data 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 need to make sure 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.


NextJS Server-Side Fetching ⚡

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

NextJS 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 to go on our client side.

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

export 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
    },
  };
};


Page View

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 may be just about enough to ship to production. However, in a realistic scenario, we have to account for cases of 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 make the best use of initial server-side fetches, retries, caching, request timeouts and more!

The first improvement we could make to the above code is to introduce 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>
  );
}

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

export 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;
}; // Remains unchanged

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),
},
};
}; // QueryClient and dehydrate() come from React Query


What has changed?

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.

The benefit we have now 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.

Checking the network tab again, we can see that a client-side fetch is being 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 to work with 🧹

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,
};

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: QUERY_FUNCTIONS[key], staleTime: 50_000 });
    return <Component {...{...props, ...queryResponse}} />;
  };
};
This HOC will wrap our page component and spread the query response props (along with page props) for us to use, 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);

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 NextJS

Happy fetching! 🔥

Find an expert mentor

Get the career advice you need to succeed. Find a mentor who can help you with your career goals, on the leading mentorship marketplace.