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> ); }
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 () => {}; // QueryClient and dehydrate() come from React Queryconst queryClient = new QueryClient()await queryClient.prefetchQuery(["companies"], fetchCompanies);return {props: {dehydratedState: dehydrate(queryClient),},};
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! 🔥