Prefetching CMS Routes with React Query on Hover
A technique for React Query for CMS Data, this guide fetches the next page’s CMS data when a reader shows intent, by hovering, focusing or scrolling a link into view, so client-side navigation renders from cache instead of showing a loading state.
In a single-page app backed by a headless CMS, every client-side navigation has a gap: the route changes, the new page’s query starts, and the reader looks at a skeleton for the length of a CMS round trip, typically 150 to 600 ms depending on region and population depth. Framework link prefetching (Next.js <Link prefetch>) covers route code and, for server components, the rendered payload. It does not cover data that client components fetch through React Query. Prefetching those queries on intent closes the gap.
The Problem
A product documentation site built with Vite, React Router and Directus loads each article through a useArticle(slug) hook. Readers navigate constantly, from the sidebar and through inline links. Every navigation shows a skeleton for around 400 ms because Directus resolves several relations per article. The team tried prefetching every link on the page at load time, which removed the skeletons and multiplied Directus traffic by fifteen, since a typical page has fifty links and readers follow two or three. Intent-based prefetching gets most of the benefit for a small fraction of that cost.
How Intent Prefetching Works
queryClient.prefetchQuery runs a query and stores the result without subscribing a component. If the data is already cached and still fresh according to the staleTime you pass, it does nothing. When the reader then navigates, the destination’s useQuery with the same key finds fresh data and renders synchronously.
Three signals indicate intent, with different reliability:
- Pointer hover on desktop precedes most clicks by a few hundred milliseconds, but many hovers never become clicks. Debounce by 60 to 100 ms to skip pointers passing over links.
- Keyboard focus is a strong signal for keyboard users and costs nothing extra to support.
- Viewport entry suits touch devices, where there is no hover. Prefetch links that stay visible for about a second, and cap how many per page.
A fourth signal, touchstart, gives roughly 100 ms of lead time on mobile. That is small, but enough for data already warm at the CDN.
Implementation
The hook returns props to spread onto any link. It shares the query key and function with the destination page through a small articleQuery options builder, the most reliable way to guarantee the prefetched entry is the one the page will read.
// lib/article-query.ts
import { queryOptions } from "@tanstack/react-query";
import { cmsKeys, type Locale } from "@/lib/cms-keys";
import { fetchArticle } from "@/lib/directus";
export const articleQuery = (slug: string, locale: Locale) =>
queryOptions({
queryKey: cmsKeys.entry("article", { slug, locale }),
queryFn: () => fetchArticle(slug, locale),
staleTime: 5 * 60 * 1000,
});
// hooks/use-prefetch-link.ts
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react";
import type { FocusEvent, PointerEvent, RefObject } from "react";
const PAGE_BUDGET = 20; // max prefetches per page view
let spent = 0;
export function resetPrefetchBudget(): void {
spent = 0;
}
interface NetworkInformationLike {
saveData?: boolean;
effectiveType?: string;
}
function allowedByNetwork(): boolean {
const conn = (navigator as Navigator & { connection?: NetworkInformationLike }).connection;
return !(conn?.saveData || conn?.effectiveType === "2g" || conn?.effectiveType === "slow-2g");
}
export function usePrefetchLink(slug: string, locale: Locale, ref: RefObject<HTMLAnchorElement>) {
const qc = useQueryClient();
const timer = useRef<number | undefined>(undefined);
const opts = articleQuery(slug, locale);
const prefetch = useCallback(() => {
if (spent >= PAGE_BUDGET || !allowedByNetwork()) return;
const state = qc.getQueryState(opts.queryKey);
if (state?.data && Date.now() - state.dataUpdatedAt < (opts.staleTime as number)) return;
spent += 1;
void qc.prefetchQuery(opts);
}, [qc, opts]);
// Touch devices: prefetch when the link stays visible for one second.
useEffect(() => {
if (!ref.current || window.matchMedia("(hover: hover)").matches) return;
let visibleTimer: number | undefined;
const io = new IntersectionObserver(([entry]) => {
window.clearTimeout(visibleTimer);
if (entry.isIntersecting) visibleTimer = window.setTimeout(prefetch, 1000);
});
io.observe(ref.current);
return () => {
io.disconnect();
window.clearTimeout(visibleTimer);
};
}, [ref, prefetch]);
return {
onPointerEnter: (_e: PointerEvent) => {
timer.current = window.setTimeout(prefetch, 80);
},
onPointerLeave: () => window.clearTimeout(timer.current),
onFocus: (_e: FocusEvent) => prefetch(),
};
}
import type { Locale } from "@/lib/cms-keys";
The destination page reads the same options, so a prefetched entry is a cache hit:
// routes/article.tsx
import { useSuspenseQuery } from "@tanstack/react-query";
import { articleQuery } from "@/lib/article-query";
export function ArticleRoute({ slug, locale }: { slug: string; locale: Locale }): JSX.Element {
const { data } = useSuspenseQuery(articleQuery(slug, locale));
return <ArticleView article={data} />;
}
Call resetPrefetchBudget() on route change, so every page view starts with a fresh budget.
Configuration Reference
| Setting | Value | Reasoning |
|---|---|---|
| Hover debounce | 80 ms | Filters pointers passing over links on the way elsewhere. |
| Visibility dwell | 1000 ms | Touch readers who pause on a link are likely to tap it. |
| Page budget | 20 prefetches | Bounds cost on link-dense pages such as sidebars. |
staleTime in options |
same as the page | Prefetches skip data that is still fresh. |
| Network guard | skip on saveData or 2g |
Respects metered and slow connections. |
| Payload | same fields as the page | A lighter prefetch query would be a different key and a cache miss. |
Gotchas & Edge Cases
- Prefetch and page using different keys. Any difference, such as a missing locale or an extra field, makes the prefetch useless. Share one
queryOptionsbuilder, as above. - Prefetching drafts. In preview mode, prefetched queries must use the preview key and preview proxy. Build the options from the same preview flag the page uses, or disable prefetching entirely in preview.
- Rate limits on dense pages. A table of contents with two hundred links can still burn the budget quickly on hover-heavy readers. The budget is per page view; lower it where links are dense.
- Server components. If the destination renders on the server, framework link prefetching already fetches its payload, and React Query prefetching of the same data is redundant. Use this pattern for client-fetched routes.
- Analytics noise. Prefetches hit your CMS proxy like real views. Exclude requests carrying a
purpose: prefetchheader (sent by browsers for<link rel=prefetch>) or a custom header of your own from view counts.
Measuring the Trade-off
Prefetching trades CMS requests for perceived speed, so measure both sides before and after rollout. Count proxy requests per page view, split by a x-prefetch: 1 header that the prefetch path adds, and record click-to-content time for client navigations. On the documentation site from the problem statement, the numbers after two weeks were clear. Intent prefetching removed the skeleton from roughly nine in ten navigations and added about half again as many CMS requests as navigations. Eager prefetching of every link removed all skeletons at more than ten times the request cost.
The shape of the curve holds broadly: the first few prefetches per page are the ones readers use, and each additional link prefetched is less likely to be followed. The page budget sits on the flat part of that curve.
Verifying the Result
In the Network panel, hover a link and watch a single request to the CMS proxy; click it, and the navigation should issue no request and show no skeleton. Hover the same link again within five minutes and nothing should be requested, because the data is fresh. Measure the effect with a web-vitals interaction metric on navigations, or simply log the time from click to first content render for prefetched and non-prefetched routes.
Rollout Checklist
- Extract a
queryOptionsbuilder for each route and use it in both the page and the prefetch. - Add the prefetch props to navigation, sidebar and inline article links first; they carry most navigations.
- Tag prefetch requests with a header so they can be counted separately.
- Start with a budget of 20 per page view and lower it where links are dense.
- Disable prefetching in preview mode unless it uses the preview key and proxy.
Frequently Asked Questions
Does prefetching on hover hurt accessibility?
No. It changes only when data is fetched, not what is rendered. Supporting focus as a trigger means keyboard users get the same benefit as mouse users, which is an improvement over hover-only approaches.
How much extra CMS traffic should I expect?
In practice, intent prefetching adds 20 to 60 percent more data requests than navigations, depending on how hover-heavy readers are. Prefetching every visible link typically adds several hundred percent. Measure with the budget counter before and after rollout.
Can the service worker do this instead?
A service worker can precache API responses, but it does not know which links a reader is about to follow, and its cache is invisible to React Query, so the page still runs its query and waits for the worker. Keep intent logic in the page, where hover and focus events are available, and let the service worker handle offline fallbacks.
Should prefetched data be stored for longer than normal?
No. Use the page’s own staleTime and the default gcTime. A prefetched entry that is never used is garbage-collected like any inactive query, which keeps memory bounded.