Optimizing React Query for Headless CMS Payloads
This guide sits under React Query for CMS Data and tackles payload-level problems. Headless CMS payloads carry deeply nested relations, localized variants, and draft/published toggles that turn default React Query configs into cache thrashing or hydration mismatches. Treat each payload as a versioned, cacheable entity with explicit lifecycle boundaries, and align client state with the Data Fetching & Caching Strategies governing CDN routing and server-side generation.
1. Hierarchical query keys
Problem: a monolithic key like ['cms', 'pages'] invalidates the entire content cache when one unrelated entry changes — redundant fetches, spiked parse time, broken localized routing.
Fix: composite keys isolating content type, locale, and publication status for granular invalidation.
// ✅ Deterministic, hierarchical key
const queryKey = [
'cms',
'entry',
'article',
{ slug: 'react-query-cms', locale: 'en-US', status: 'published' }
];
// ❌ Flat, non-deterministic key
const flatKey = ['cms', 'pages'];
Prevention: enforce a key schema with a factory function; never build keys inline in components. Mirror the exact query variables or URL params from the fetch layer so keys can’t collide. This is the foundation React Query for CMS Data builds on when scaling to micro-frontends.
2. Normalize relations at the fetch boundary
Problem: CMS responses return nested trees with duplicated author, media, and cross-linked references. React Query serializes each nested object independently, so identical sub-objects occupy multiple cache slots — bloated memory, broken reference equality, needless re-renders.
Fix: flatten into a normalized lookup table before the cache, reconstruct the tree in a selector.
import { normalize, schema } from 'normalizr';
const articleSchema = new schema.Entity('articles');
const authorSchema = new schema.Entity('authors');
articleSchema.define({ author: authorSchema });
export async function fetchAndNormalizeArticle(slug: string) {
const response = await fetch(`/api/cms/articles/${slug}`);
const raw = await response.json();
// Flatten into entities + result structure
const normalized = normalize(raw, articleSchema);
return normalized;
}
// In component
const { data } = useQuery({
queryKey: ['article', slug],
queryFn: () => fetchAndNormalizeArticle(slug),
});
const article = useMemo(() => reconstructTree(data), [data]);
Prevention: normalize at the API client layer, not in UI components, and document the normalized schema alongside the content model. Apollo Client GraphQL Caching does this automatically via InMemoryCache; React Query needs the explicit transform. Consistent normalization prevents thrashing when editors update shared references like global navigation.
3. Profile-driven cache lifecycles
Problem: uniform staleTime and gcTime across all content either over-refetches static marketing pages or bloats memory for dynamic widgets.
Fix: a CacheProfile enum and a useCmsQuery wrapper mapping profiles to explicit configs.
type CacheProfile = 'static' | 'dynamic' | 'preview';
const CACHE_PROFILES: Record<CacheProfile, { staleTime: number; gcTime: number }> = {
static: { staleTime: Infinity, gcTime: 300_000 },
dynamic: { staleTime: 0, gcTime: 30_000 },
preview: { staleTime: 0, gcTime: 60_000 },
};
export function useCmsQuery<T>(queryKey: QueryKey, fetcher: () => Promise<T>, profile: CacheProfile) {
const config = CACHE_PROFILES[profile];
return useQuery({
queryKey,
queryFn: fetcher,
staleTime: config.staleTime,
gcTime: config.gcTime,
refetchOnWindowFocus: profile === 'dynamic',
});
}
Prevention: marketing pages use static with webhook-triggered invalidation; comment feeds and personalized recommendations use dynamic with aggressive GC. Standardizing profiles removes ad-hoc tuning and aligns with SWR Stale-While-Revalidate Patterns for predictable background updates.
4. Sync edge revalidation with client state
Problem: when ISR or CDN routing sits on top of client caching, ISR revalidates a page at the edge while React Query keeps a stale client snapshot — returning visitors see content drift.
Fix: inject a x-cms-cache-version header from the edge and append it to the query key. When the version changes, React Query treats it as a new query and refetches.
// Next.js Edge Middleware or API Route
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
const cacheVersion = req.cookies.get('cms-version')?.value || Date.now();
res.headers.set('x-cms-cache-version', cacheVersion);
return res;
}
// Client Component
const { data } = useQuery({
queryKey: ['cms', 'page', slug, { version: headers['x-cms-cache-version'] }],
queryFn: () => fetchPage(slug),
});
Prevention: don’t rely on refetchOnMount for ISR-synced content. Compare the current x-cms-cache-version against a stored cookie in a useEffect; on mismatch, call queryClient.invalidateQueries(). This bridges Content Delivery Network Routing Logic and client hydration so visitors always get the latest published revision. (For the edge side, see Next.js ISR Implementation.)
5. Eliminate hydration mismatches
Problem: draft/published toggles or locale fallbacks make server HTML differ from client hydration. React warns, falls back to client-only rendering, and breaks SEO indexing.
Fix: dehydrate the exact query state at build time and rehydrate with strict key matching; disable client fetching for preview until after mount.
// Server-side (Next.js App Router)
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['cms', 'entry', 'article', { slug, locale, status: 'published' }],
queryFn: fetchArticle,
});
const dehydratedState = dehydrate(queryClient);
// Client-side
const HydrateProvider = ({ children, state }) => (
<HydrationBoundary state={state}>{children}</HydrationBoundary>
);
// Prevent client override during hydration
const { data } = useQuery({
queryKey: ['cms', 'entry', 'article', { slug, locale, status }],
queryFn: fetchArticle,
enabled: typeof window !== 'undefined' && status === 'preview',
});
Prevention: serialize the exact query state during server generation and add strict type guards for localized fallback chains. Run Automated Testing for Headless Integrations that simulate draft-to-publish transitions and assert hydration boundaries hold. Never let a client useEffect fetch override the server-rendered payload during initial hydration.
Configuration Reference
| Option | Suggested value | Notes |
|---|---|---|
| Key shape | ["cms", kind, type, params] |
Built only by a factory; params object includes locale and status. |
staleTime static profile |
Infinity + webhook invalidation |
Marketing and docs pages that change rarely. |
staleTime dynamic profile |
0 to 30_000 |
Feeds and widgets that change often. |
gcTime |
5 min default; 1 min for preview | Preview drafts are short-lived and should not linger. |
select |
memoized selector | Derive view models without re-rendering on unrelated field changes. |
structuralSharing |
true (default) |
Keeps unchanged sub-objects referentially equal after refetches. |
select and structural sharing work together. When a refetch returns an identical payload, structural sharing keeps the previous object references, and a stable select function then returns the same derived value, so components do not re-render at all. Define select outside the component or wrap it in useCallback; an inline arrow creates a new function on every render and defeats the memoization.
Measuring the Effect
Before and after each change, record three numbers in a production-like build: the total serialized size of the query cache (JSON.stringify(queryClient.getQueryCache().getAll().map((q) => q.state.data)).length), the number of queries in the cache after a typical ten-page browsing session, and the render count of a representative list component during a background refetch, measured with the React Profiler. Hierarchical keys shrink the query count by removing accidental duplicates. Normalization and field selection shrink the payload size. Structural sharing and stable selectors bring the render count during a no-change refetch down to zero.
The order of these fixes matters. Start with keys, because every later fix depends on stable identity. Then trim payloads with field selection, which is usually the largest single saving and needs no client code. Normalize only when shared entities such as authors or categories actually repeat across many items; for simple pages it adds code without much benefit.
Gotchas & Edge Cases
- Dates in keys. A key containing
new Date()orDate.now()is a new query on every render. Put only serializable, stable values in keys. - Normalization and structural sharing. Normalizing into new objects on every fetch defeats structural sharing, because every entity is a fresh reference. Normalize inside
selectwith a memoized selector, or accept one re-render per refetch. - Locale fallbacks during hydration. If the server resolved a fallback locale and the client key uses the requested locale, the client misses the hydrated entry and refetches. Put the resolved locale chain in the key on both sides.
- Preview keys leaking into production. A
status: "preview"key that is prefetched on the server for a production visitor means a preview token was used in a public render. Guard preview prefetching behind draft mode checks.
Frequently Asked Questions
Should I use normalizr or write my own normalization?
normalizr is small and handles circular references, but it is no longer actively developed. For simple CMS models, a hand-written normalizer of twenty lines keyed by sys.id or documentId is easier to type precisely with TypeScript. Choose whichever your team can maintain.
How do I invalidate a shared author when it changes?
With normalized data, authors are separate queries (["cms", "entry", "author", { id }]), and a webhook for the author invalidates only that key. Components that display the author read it through a selector and update automatically. Nested payloads, by contrast, require invalidating every article that embeds the author.
Does field selection break when editors add fields?
No. Selection is an allow-list, so new fields are simply not fetched until you add them. The risk runs the other way: removing a field from the CMS that the selection still lists. Validate responses with a schema so that surfaces as a clear error.
Is a key factory enough to prevent collisions between environments?
Only if the environment is part of the key or the cache is per environment. A single browser session normally talks to one environment, so the cache is effectively scoped already. Tools that switch environments at runtime, such as an editor dashboard comparing staging and production, should add the environment to the key parameters.