React Query vs SWR for CMS Data: Architecture, Caching & Implementation
Choosing between React Query (RQ) and Stale-While-Revalidate (SWR) dictates how your frontend handles Content Management System (CMS) payloads. Both libraries solve the hydration gap. Their caching philosophies diverge significantly. SWR prioritizes lightweight, key-based revalidation. RQ enforces strict query normalization and explicit lifecycle control. This guide maps their architectures to production Jamstack workflows.
Core Architecture & Cache Lifecycle
SWR operates on a string-key paradigm. You pass a URL or identifier. The library manages the fetch lifecycle automatically. React Query uses an array-based query key system. This enables deep structural matching and granular invalidation.
Both handle background refetching and request deduplication. They prevent duplicate network calls when multiple components mount simultaneously. Retry policies differ in configurability. SWR defaults to exponential backoff. RQ exposes explicit retry counts and delay functions.
Understanding these patterns is foundational to modern Data Fetching & Caching Strategies for headless architectures.
| Feature | React Query | SWR |
|---|---|---|
| Cache Key | Array-based, structural matching | String/URL-based, exact match |
| Default Refetch | On mount, window focus, reconnect | On mount, window focus, reconnect |
| Mutation Handling | Dedicated useMutation, optimistic UI |
Inline mutate() callback |
| DevTools | Dedicated browser extension | Basic console logging |
| Bundle Size | ~13kb gzipped | ~4kb gzipped |
ISR Alignment & Edge Caching Integration
Incremental Static Regeneration (ISR) bridges static generation and dynamic updates. React Query’s staleTime dictates when data is considered fresh. Align this with your Next.js revalidate interval. This prevents redundant fetches.
SWR’s revalidateOnFocus and dedupingInterval control client-side polling. These settings must complement your Content Delivery Network (CDN) Time To Live (TTL). Mismatched intervals cause cache stampedes.
For detailed Next.js integration, review Implementing ISR with Next.js and Headless CMS.
Webhook invalidation requires explicit triggers. Your CMS publishes content and sends a payload to your serverless endpoint. That endpoint calls queryClient.invalidateQueries() or SWR’s mutate(). This forces a fresh fetch on the next render cycle.
Edge Network Optimization & Latency Reduction
Client-side cache misses during initial hydration hit your origin server. This increases Time To First Byte (TTFB). You must align client fetchers with edge caching layers.
Configure your fetcher to respect Cache-Control headers. This prevents origin overload during traffic spikes. See Edge Caching Strategies for Headless APIs for CDN routing patterns.
Cache invalidation propagates in three stages: origin CMS purge, CDN cache bypass, and client state reset. Use staleTime: 0 immediately after a webhook event. Revert to standard intervals once fresh data arrives.
GraphQL Query Batching & State Normalization
GraphQL endpoints return nested payloads. React Query’s queryClient.setQueryData allows surgical cache updates. SWR’s mutate() accepts a callback for partial state replacement.
Batching multiple CMS queries into a single HTTP request reduces latency. It complicates cache invalidation. You lose granular control over individual entities.
For complex GraphQL schemas, consider Using Apollo Client with Contentful GraphQL API as a dedicated normalization layer.
Optimistic updates improve perceived performance during draft/publish workflows. Update the cache immediately with the pending state. Roll back automatically if the mutation fails.
Implementation Blueprint & Configuration
Production setups require typed fetchers, environment configuration, and explicit error boundaries. Below are hardened implementations for both libraries.
React Query CMS Fetcher with Webhook Invalidation
import { useQuery, useQueryClient } from '@tanstack/react-query';
const CMS_BASE_URL = process.env.NEXT_PUBLIC_CMS_API_URL;
export const useCmsData = (slug: string) => {
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['cms-page', slug],
queryFn: async () => {
// 👈 Critical: Attach headers to align with edge nodes
const res = await fetch(`${CMS_BASE_URL}/pages/${slug}`, {
headers: { 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600' },
next: { revalidate: 300 }
});
if (!res.ok) throw new Error(`CMS fetch failed: ${res.status}`);
return res.json();
},
staleTime: 1000 * 60 * 5, // 👈 Critical: Prevents background refetches for 5m
gcTime: 1000 * 60 * 30,
refetchOnWindowFocus: false,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['cms-page'] });
return { data, isLoading, error, invalidate };
};
Flow: The hook initializes a normalized query key. The fetcher attaches Cache-Control headers. staleTime prevents unnecessary background refetches. The invalidate function exposes a webhook handler for content updates.
SWR CMS Fetcher with ISR Revalidation Hook
import useSWR from 'swr';
const CMS_BASE_URL = process.env.NEXT_PUBLIC_CMS_API_URL;
const fetcher = async (url: string) => {
// 👈 Critical: Enforce cache headers at the transport layer
const res = await fetch(url, {
headers: { 'Cache-Control': 'public, max-age=600, stale-while-revalidate=1200' },
});
if (!res.ok) throw new Error(`CMS fetch failed: ${res.status}`);
return res.json();
};
export const useCmsPage = (slug: string) => {
const { data, error, mutate } = useSWR(
`${CMS_BASE_URL}/pages/${slug}`,
fetcher,
{
revalidateOnFocus: false,
refreshInterval: 0,
dedupingInterval: 2000, // 👈 Critical: Prevents rapid successive calls
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
if (retryCount >= 3) return;
setTimeout(() => revalidate({ retryCount }), 1000 * retryCount);
}
}
);
const triggerRevalidation = async () => {
await mutate(undefined, { revalidate: true }); // 👈 Critical: Forces fresh fetch
};
return { data, error, triggerRevalidation };
};
Flow: SWR binds directly to the URL string. The fetcher enforces cache headers. dedupingInterval prevents rapid successive calls. onErrorRetry implements custom backoff logic. triggerRevalidation forces a fresh fetch on webhook receipt.
For performance metrics, consult SWR vs React Query for Jamstack data fetching.
DX Tradeoffs & Decision Matrix
| Aspect | React Query | SWR |
|---|---|---|
| Strengths | Advanced normalization, built-in mutations, granular lifecycle control, dedicated DevTools | Lightweight footprint, intuitive API, automatic focus/network revalidation, minimal boilerplate |
| Weaknesses | Larger bundle (~13kb), steeper key convention curve, requires explicit webhook invalidation | Limited mutation patterns, less precise lifecycle control, string keys risk collisions |
Choose React Query for complex CMS architectures requiring granular invalidation, optimistic UI, and multi-entity normalization. Choose SWR for lightweight Jamstack sites prioritizing fast hydration, simple key-based caching, and minimal configuration overhead.
Implementation Workflow
- Audit CMS endpoint latency, payload structure, and webhook capabilities.
- Select library based on DX tradeoffs, bundle constraints, and team familiarity.
- Configure global provider (
QueryClientProviderorSWRConfig) with CMS-specific defaults. - Implement typed fetcher with proper error handling and retry logic.
- Set up webhook listener to trigger
invalidateQueries()ormutate()on CMS publish events. - Align
staleTime/gcTimewith ISR/edge cache TTLs to prevent cache stampedes. - Add loading/error boundaries and fallback UI for content team preview modes.
Common Pitfalls
- Cache Stampede: Setting
staleTime: 0globally forces every component to refetch on mount. Use positive intervals and rely on background revalidation. - Key Collisions: SWR string keys fail when query parameters change. Always serialize complex filters into stable identifiers.
- Hydration Mismatch: Server-rendered data must match client cache keys exactly. Pre-populate
dehydrate/hydrateorfallbackprops to avoid layout shifts. - Unbounded Memory Growth: React Query’s
gcTimedefaults to 5 minutes. Increase it for heavy CMS payloads, but monitor browser memory in long-lived admin dashboards.
FAQ
Q: Can I use both libraries in the same project? A: Yes, but avoid overlapping cache management. Isolate them by domain. Use React Query for complex admin mutations and SWR for public-facing content hydration.
Q: How do I handle CMS preview mode?
A: Bypass client caching entirely. Pass staleTime: 0 and refetchOnMount: true when process.env.NEXT_PUBLIC_PREVIEW_MODE === 'true'.
Q: Does SWR support optimistic updates?
A: Yes, via the mutate callback. Pass the updated data as the second argument, then return the server response. It requires manual rollback logic on failure.
Q: Which library handles GraphQL better? A: React Query’s array keys map cleanly to GraphQL variables. SWR requires manual string serialization. For heavy GraphQL, a normalized client often outperforms both.