Server prefetching
Prefetch on the server, hydrate in the browser, keep the keys identical, and use fetchQuery when the server needs the data itself.
From: the resort website and the company website. Both are public, SEO-heavy sites where every page's content comes from a CMS.
The pattern
The server component fills the cache, and the client components read from it. The HTML arrives with content already in it, and the browser doesn't fetch the same data again.
Prefetch in the page, in parallel
export default async function HomePage() {
const queryClient = getQueryClient();
await Promise.all([
queryClient.prefetchQuery(homeContentQueryOptions()),
queryClient.prefetchQuery(roomsQueryOptions()),
queryClient.prefetchQuery(galleryQueryOptions()),
queryClient.prefetchQuery(testimonialsQueryOptions(HOME_TESTIMONIALS_PARAMS)),
queryClient.prefetchQuery(breadcrumbByPageQueryOptions('home')),
queryClient.prefetchQuery(eventsQueryOptions({ limit: 100 })),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<LandingPage />
</HydrationBoundary>
);
}Promise.all runs the requests side by side. Awaiting them one by one would
add every request's time together.
Read with useSuspenseQuery in the section
'use client';
function AboutSectionContent() {
const { data } = useSuspenseQuery(homeContentQueryOptions());
// ...render data
}The data is already in the cache, so this returns immediately on the first
render. useSuspenseQuery also types data as always defined, so there's no
if (!data) branch.
Wrap each section in a boundary
If a query does have to fetch in the browser, or fails, the section needs a loading and an error state. See Loading and error boundaries.
The server and the client must build the same key
On the resort website's gallery page, the server only prefetched the breadcrumb. The gallery list was fetched in the browser, after hydration. That made a waterfall: download the page, hydrate, request the gallery list, and only then discover the image URLs and start downloading images.
Prefetching the list on the server fixed it, but only because the server and the client ask for the same page size. I moved the limit into a shared constant so they can't drift apart:
export const GALLERY_PAGE_LIMIT = 100;const galleryListQueryOptions = galleryQueryOptions({ limit: GALLERY_PAGE_LIMIT });
async function GalleryPage() {
const queryClient = getQueryClient();
// Both are prefetched server-side so the client never waterfalls
// (hydrate → fetch list → discover image URLs) before images can download.
await Promise.all([
queryClient.prefetchQuery(breadcrumbByPageQueryOptions('gallery')),
queryClient.prefetchQuery(galleryListQueryOptions),
]);
// ...
}A mismatched key fails silently
If the server prefetches { limit: 100 } and the client asks for
{ limit: 12 }, nothing errors. The prefetched data just sits unused, and
the browser makes its own request. Share the params, not just the options
factory.
fetchQuery when the server needs the data
prefetchQuery fills the cache and returns nothing. It also never throws, so
it can't tell you a page doesn't exist. The company website's detail pages needed the
data on the server twice: once for generateMetadata, and once to call
notFound(). They used to call the API directly for both, and then prefetch
on top:
export async function generateMetadata({ params }) {
const { slug } = await params;
const project = await getCaseStudyDetail(slug); // request 1
// ...
}
export default async function CaseStudyDetailPage({ params }) {
const { slug } = await params;
try {
await getCaseStudyDetail(slug); // request 2
} catch {
notFound();
}
const queryClient = getQueryClient();
await queryClient.prefetchQuery(caseStudyDetailQueryOptions(slug)); // request 3
// ...
}I changed both to fetchQuery with the same query options:
export async function generateMetadata({ params }) {
const { slug } = await params;
const queryClient = getQueryClient();
const project = await queryClient.fetchQuery(caseStudyDetailQueryOptions(slug));
// ...
}
export default async function CaseStudyDetailPage({ params }) {
const { slug } = await params;
const queryClient = getQueryClient();
try {
await queryClient.fetchQuery(caseStudyDetailQueryOptions(slug));
} catch {
notFound();
}
// ...the cache is already filled, so dehydrate() includes it
}getQueryClient() returns the same client for the whole request (it's wrapped
in React's cache()), and the data is fresh for the staleTime. So the second
fetchQuery reads the cache instead of the network, and the same entry is
what gets sent to the browser. I made the same change on the blog, product and
case study detail pages.
| Question | prefetchQuery | fetchQuery |
|---|---|---|
| Returns the data | No | Yes |
| Throws on error | No | Yes |
| Use it for | Filling the cache for client components | generateMetadata, notFound(), anything the server reads |
One bulk query for the home page
A CMS-driven home page has many sections, and each one used to have its own endpoint and its own query. On the company website's home page that meant ten prefetches. Once the API had one endpoint that returns every section's content, I moved the page onto it:
await Promise.all([
+ queryClient.prefetchQuery(homeContentDetailsQueryOptions),
queryClient.prefetchQuery(testimonialsQueryOptions),
- queryClient.prefetchQuery(homeCoreServicesQueryOptions),
- queryClient.prefetchQuery(homeWhyUsQueryOptions),
- queryClient.prefetchQuery(homeTestimonialsQueryOptions),
- queryClient.prefetchQuery(homeFeaturedWorkQueryOptions),
- queryClient.prefetchQuery(homeLatestBlogQueryOptions),
queryClient.prefetchQuery(partnerListQueryOptions()),
- queryClient.prefetchQuery(industriesListQueryOptions()),
queryClient.prefetchQuery(workflowListQueryOptions()),
- queryClient.prefetchQuery(recognizedByListQueryOptions()),
]);Each section reads its own slice of the one query:
const { data } = useSuspenseQuery(homeContentDetailsQueryOptions);
const industries = data.industries;Ten requests became five. I'd done the same on the resort website's home page earlier, moving its sections onto one bulk home content endpoint.
When a bulk query fits
Use one query when the sections always load together, on the same page, and change at the same time (through the same CMS). Keep separate queries for data that other pages also use on its own, like testimonials or partners.
Last updated on