The following content is a technical guide for enhancing Sitecore code bases.
Without care, implementing data fetching can get messy.
Nearing three million weekly downloads, TanStack Query is a popular choice for good reason. It greatly simplifies data handling.
Following is a guide for how to configure TanStack Query with Sitecore's JSS boilerplate to achieve dynamic hydration.
What is dynamic hydration? Well, Sitecore CMS provides dynamic pages with dynamic design orchestrated by authors dropping components onto pages. So, how can a developer best pair server-side data fetching to the component that needs it and how does that affect client-side component code?
Spoiler: it doesn't need to affect the code at all! This is dynamic hydration.
For context, as a developer, we’d like a way to preload data server side during the static site generation (SSG/ISR) to bake it into the client bundle and provide the data to any client side components that need it. Ideally, this can be managed within the scope of the component to fully embrace component-driven design. Sitecore plugins make this clean and simple.
Intro to Dynamic Hydration with Sitecore
What if I told you this simple useQuery block...
const ShowCatFact = (): JSX.Element => { | |
const query = useQuery({ | |
queryKey: ['uniqueString'], // unique key to access fetched data | |
queryFn: fetchCatFactAsync, // function to (re)fetch data | |
}); | |
// ... | |
return <p>{query.isLoading ? '...' : query.data.fact}</p> | |
} |
... can be set up in your code to handle:
All the normal React Query benefits:
- Succinct data fetch management (data, loading, success, error)
- TypeScript support
- Client cache of data
- Stale while revalidate - show old data when fetching fresh data
- Controls for when to refetch data
- Sharing data result across components
- And more!
But also, dynamic hydration:
- Seamless Static Site Generation (SSG), Server-Side Rendering (SSR), and Incremental Static Regeneration (ISR) support – hydrate client with server-side data
- This means no client-side fetch, data is used in server-side rendering
- Seamless – React components don't care. Whether data was fetched server-side or not, the useQuery code doesn't change.
- Component level data fetch – with Sitecore's component level data fetching, server-side data calls can live at the Sitecore component level
- And no extra code once set up!
This extra magic leverages Sitecore's plugin pattern, adding a step in the page-props-factory. This solution is designed for the Next.js Pages Router and Sitecore JSS SDK.
Step-By-Step Guide to Server-Side Data Prefetching in Sitecore
The process of setting up server-side data prefetching in Sitecore, ensuring efficient data handling and optimal performance through a systematic approach for dynamic hydration with TanStack Query.
Setting Up the Foundation with TanStack Query
For completeness, let's cover the initial setup. This layer lets us do client-side queries with useQuery.
Step 1 – QueryClientProvider
Add TanStack Query: npm i @tanstack/react-query (Docs)
- Also add dev tools: npm i @tanstack/react-query-devtools
Initialize QueryClientProvider in app root
- Implement as separate file, ReactQueryClientProvider.tsx
- Add ReactQueryClientProvider to _app.tsx
// NEW FILE: /src/lib/providers/ReactQueryClientProvider.tsx | |
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | |
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; | |
import { useState } from 'react'; | |
type ReacQueryClientProviderProps = { | |
children: React.ReactElement; | |
}; | |
function ReacQueryClientProvider({ children }: ReacQueryClientProviderProps): JSX.Element { | |
// ReactQuery: use state for queryClient, which ensures each request has its own cache: | |
const [queryClient] = useState( | |
() => | |
new QueryClient({ | |
defaultOptions: { | |
queries: { | |
// Any global behavior defaults here: | |
refetchOnMount: false, | |
refetchOnReconnect: false, | |
refetchOnWindowFocus: false, | |
// With SSR, we usually want to set some default staleTime | |
// above 0 to avoid refetching immediately on the client | |
staleTime: Infinity, | |
gcTime: Infinity, | |
}, | |
}, | |
}) | |
); | |
return ( | |
<QueryClientProvider client={queryClient}> | |
{/* ^ ADD the QueryClient provider with our shared client (cache) instance */} | |
{children} | |
<ReactQueryDevtools /> | |
{/* ^ can ADD dev tools */} | |
</QueryClientProvider> | |
); | |
} | |
export default ReactQueryClientProvider; |
// EDIT FILE: /src/pages/_app.tsx | |
import ReactQueryClientProvider from '@/lib/providers/ReactQueryClientProvider'; | |
import '@/sass/main.scss'; | |
import { SitecorePageProps } from '@/types/props/page-props'; | |
import { I18nProvider } from 'next-localization'; | |
import type { AppProps } from 'next/app'; | |
import Bootstrap from 'src/Bootstrap'; | |
function App({ Component, pageProps }: AppProps<SitecorePageProps>): JSX.Element { | |
const { dictionary, locale, ...rest } = pageProps; | |
return ( | |
<> | |
<Bootstrap {...pageProps} /> | |
<I18nProvider lngDict={dictionary} locale={locale}> | |
<ReactQueryClientProvider> | |
{/* ^ ADD new QueryClientProvider HERE to cover every page route */} | |
<Component {...rest} /> | |
</ReactQueryClientProvider> | |
</I18nProvider> | |
</> | |
); | |
} | |
export default App; |
Step 2 – Demo UseQuery
Now that we have the global query client provider, we can put useQuery in any React component. For example:
// NEW FILE: /src/components/example/ShowCatFact.tsx | |
import { useQuery } from '@tanstack/react-query'; | |
import { CatFactQueryKey, fetchCatFactAsync } from './fetch'; | |
const ShowCatFact = (): JSX.Element => { | |
const query = useQuery({ | |
queryKey: [CatFactQueryKey], | |
queryFn: fetchCatFactAsync, | |
}); | |
return ( | |
<div> | |
<h1>Cat Fact</h1> | |
<p>{query.isLoading ? '...' : query.data.fact}</p> | |
</div> | |
); | |
}; | |
export default ShowCatFact; |
Step 3 – Implement Fetch Function
Here is the example fetch method referenced above:
// NEW FILE: /src/components/example/fetch.ts | |
import { NativeDataFetcher } from '@sitecore-jss/sitecore-jss-nextjs'; | |
/* ==================== HTTP Fetch Example ================================ | |
/* | |
* A Fetch Method implements how to get the data. | |
* Here we will return a random cat fact from http endpoint | |
*/ | |
export const CatFactQueryKey = 'catFactQueryKey'; | |
export type TExampleCatFactResult = { | |
fact: string; | |
length: number; | |
}; | |
export async function fetchCatFactAsync(): Promise<TExampleCatFactResult> { | |
/* Use Sitecore Fetcher which has built-in debug logging */ | |
const fetcher = new NativeDataFetcher(); | |
const response = await fetcher.fetch<TExampleCatFactResult>('https://catfact.ninja/fact'); | |
return response.data; | |
} |
Implementing a Dynamic Hydration Layer for Optimal Performance
So far, we've enabled client-side query management and the ability to share data between components using the same cache key. Next, we'll implement the server-side prefetching with dynamic hydration.
Tip: Getting the data server-side for SSG/ISR can reduce the number of calls to your remote data endpoint which in many cases can reduce API costs.
Step 4 – Extend Page Props
We need to pass our server-side query data to the page components, so let's add a new page prop "dehydratedState":
// EDIT FILE: /src/lib/page-props.ts | |
// ... | |
import { DehydratedState } from '@tanstack/react-query'; | |
export type SitecorePageProps = { | |
// ... | |
dehydratedState?: DehydratedState; | |
// ^ ADD page prop | |
}; |
Step 5 – Hydration Boundary
We need to implement TanStack Query's Hydration Boundary to inject our server-side data. Let's do it in our dynamic page route:
// EDIT FILE: /src/pages/[[...path]].tsx | |
// ... | |
import { HydrationBoundary } from '@tanstack/react-query'; | |
const SitecorePage = ({ | |
// ... | |
dehydratedState, | |
// ^ EXPOSE new page prop | |
}: SitecorePageProps): JSX.Element => { | |
// ... | |
return ( | |
<HydrationBoundary state={dehydratedState}> | |
{/* ^ ADD HydrationBoundary, set state to page prop resolved by page-props-factory plugin | |
This can be moved to _app.tsx to apply to all routes. | |
Kept here to support all Sitecore routes, allowing other routes to opt-in. */} | |
<ComponentPropsContext value={componentProps}> | |
{/* ... */} | |
</ComponentPropsContext> | |
</HydrationBoundary> | |
); | |
}; |
Step 6 – Query Data (SSG/ISR/SSR)
Let's populate that page prop with an example server-side query. We'll show how to do it at Sitecore's component level:
// NEW FILE: /src/components/SitecoreComponent.tsx | |
import ShowCatFact from '@/components/example/ShowCatFact'; | |
import { CatFactQueryKey, fetchCatFactAsync } from '@/components/example/fetch'; | |
import { GetStaticComponentProps } from '@sitecore-jss/sitecore-jss-nextjs'; | |
import { QueryClient, dehydrate } from '@tanstack/react-query'; | |
const FetchExample = (): JSX.Element => { | |
return <ShowCatFact />; | |
}; | |
// This is called by Sitecore when Page route uses GetStaticProps, for SSG/ISR | |
export const getStaticProps: GetStaticComponentProps = async () => { | |
// 1. Instantiate QueryClient server side | |
const queryClient = new QueryClient(); | |
// 2. Run one or more queries to add results to cache | |
await queryClient.prefetchQuery({ | |
queryKey: [CatFactQueryKey], | |
queryFn: fetchCatFactAsync, | |
}); | |
return { | |
props: { | |
// 3. send dehydrated result cache for client side component props | |
dehydratedState: dehydrate(queryClient), | |
}, | |
}; | |
}; | |
export default FetchExample; |
Step 7 – Page Props Plugin
And now the grand finale, how to aggregate all the component level fetch methods into our new dehydratedState page prop:
// NEW FILE: /src/lib/page-props-factory/plugins/dehydration-props.ts | |
import { DehydratedStateProps } from '@/types/props/component-props'; | |
import { SitecorePageProps } from '@/types/props/page-props'; | |
import { DehydratedState } from '@tanstack/react-query'; | |
import merge from 'ts-deepmerge'; | |
import { Plugin } from '..'; | |
/* | |
React Query Seamless Hydration Plugin | |
Allow component level GetServerSideProps|GetStaticProps to fetch and dehydrate data. | |
Aggregate here to page props level to send to <HydrationBoundary /> (** see [[...path]].tsx) | |
This passes server side data to client side cache, saving redundant client-side queries. | |
*/ | |
class DehydratedStatePlugin implements Plugin { | |
order = 3; // run after component-props plugin | |
async exec(props: SitecorePageProps) { | |
// Bubble up all component level dehydratedState props to this page level prop | |
let data: DehydratedState = { | |
mutations: [], | |
queries: [], | |
}; | |
// iterate all components declared in layout service response | |
if (props.componentProps) { | |
for (const p in props.componentProps) { | |
const thisProps = props.componentProps[p] as DehydratedStateProps; | |
// if has query state, merge it up | |
if (!!thisProps?.props?.dehydratedState) { | |
data = merge(data, thisProps.props?.dehydratedState); | |
} | |
} | |
} | |
// pass combined state to page props | |
props.dehydratedState = data; | |
return props; | |
} | |
} | |
// Export name must be <normalizedFileName>+"Plugin" | |
export const dehydrationPropsPlugin = new DehydratedStatePlugin(); |
Important: notice the order property. This step must come after the component-props step.
Also, after adding that plugin file, run Sitecore's bootstrap task npm run bootstrap to kick off Sitecore's script that builds the temp file needed for that plugin.
Validating the Setup: How to Test Dynamic Hydration
A simple test to verify if all is working is to open up the browser dev console. When you don't have the server-side query, you will see the network request for your data fetch. When you have the server-side prefetch and you don't see the network request for the cat fact client side, then you know the hydration is working as expected.
Wrapping Up: Seamless Data Fetching Across Environments
With this plumbing in place, we can implement useQuery in any React component without concern of when the data is loaded. For cases when we want prefetching server-side, we can opt in by passing the dehydratedState page prop from GetStaticProps/GetServerSideProps. We can even do this at the Sitecore component level GetStaticProps/GetServerSideProps to isolate usage for that component.
Bonus!
As an extra, here is a utility to collapse the server-side work into a one liner (see example usage in comments below):
// NEW FILE: /src/lib/utils/dehydrateQueries.ts | |
import { DehydratedState, QueryClient, dehydrate } from '@tanstack/react-query'; | |
export type QueryInput = [string[], () => unknown]; | |
/* | |
Fetch one or more queries in parallel server-side. | |
Returns dehydrated query client for page prop. | |
Input format - [queryKey, queryFn] pairs: | |
await dehydrateQueriesAsync([ | |
[['queryKey'], fetchFunction], | |
[['queryKey', var1, var2], () => myFetch(var1, var2)], | |
]) | |
Example usage: (in GetStaticProps/GetServerSideProps): | |
return { | |
props: { | |
dehydratedState: await dehydrateQueriesAsync([[CatFactQueryKey], fetchCatFactAsync]), | |
}, | |
}; | |
*/ | |
export const dehydrateQueriesAsync = async (queries: QueryInput[]): Promise<DehydratedState> => { | |
const queryClient = new QueryClient(); | |
await Promise.all( | |
queries.map((x): void => { | |
const [queryKey, queryFn] = x; | |
queryClient.prefetchQuery({ | |
queryKey, | |
queryFn, | |
}); | |
}) | |
); | |
return dehydrate(queryClient); | |
}; |
And here is the updated example SitecoreComponent using that dehydrateQueriesAsync utility:
// REPLACE NEW FILE: /src/components/SitecoreComponent.tsx | |
import ShowCatFact from '@/components/example/ShowCatFact'; | |
import { CatFactQueryKey, fetchCatFactAsync } from '@/components/example/fetch'; | |
import { GetStaticComponentProps } from '@sitecore-jss/sitecore-jss-nextjs'; | |
import { QueryClient, dehydrate } from '@tanstack/react-query'; | |
const FetchExample = (): JSX.Element => { | |
return <ShowCatFact />; | |
}; | |
export const getStaticProps: GetStaticComponentProps = async () => { | |
return { | |
props: { | |
// Condense any server-side queries to one line: | |
dehydratedState: await dehydrateQueriesAsync([[CatFactQueryKey], fetchCatFactAsync]), | |
}, | |
}; | |
}; | |
export default FetchExample; |
Work with a Certified Sitecore Partner
Americaneagle.com is a Sitecore website development partner with years of experience devoted to providing exceptional end-to-end digital experiences that drive results. We offer a range of Sitecore development services, some of which include enterprise implementations and integrations, ongoing support and managed services, solution audits, version updates, and more.
To learn more about how our Sitecore practice, contact us today.