Optimizing React Query for headless CMS payloads

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.

TypeScript
// ✅ 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.

TypeScript
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.

TypeScript
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.

TypeScript
// 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.

TSX
// 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.