React Query for CMS Data

Decoupling a CMS from its frontend pushes data freshness, cache coordination, and update propagation onto the client. React Query (TanStack Query) handles that by treating server state as a first-class concern: request deduplication, background sync, and predictable cache lifecycles without manual reducers. Within the broader Data Fetching & Caching Strategies section, it’s the pragmatic default for REST/JSON CMS payloads, and this topic covers the full contract: keys, lifecycles, invalidation, preview, errors and testing.

Where React Query sits between components and the CMSComponents call hooks built on a query-key factory; the QueryClient serves cached data or calls a typed fetcher that hits a server-side CMS proxy, which holds the token and talks to the CMS REST API.ComponentsuseArticle(slug)Key factorycmsKeys.article(...)QueryClientcache + dedupeTyped fetcherzod-validated/api/cms proxyholds tokenCMS REST APImiss or stale
The proxy keeps CMS tokens off the client; the key factory is the single place that decides cache identity.

Integration Contract

React Query does not know anything about your CMS. It caches whatever a query function returns, under whatever key you give it. The integration contract is therefore something you define, and it has four parts worth writing down before building hooks.

Transport and auth. Browser code should call a thin server-side proxy (/api/cms/* in Next.js, a Remix resource route, or an edge function) rather than the CMS directly. The proxy holds the delivery token, adds locale and environment parameters, and can apply its own short CDN cache. Calling Contentful’s CDA, Strapi’s REST API or Directus directly from the browser works only when the token is strictly scoped to published content, and even then it exposes your space id and makes token rotation a frontend deploy.

Key schema. Every query key starts with a stable prefix ("cms"), then the content type, then an object of parameters that change the result: slug or id, locale, preview flag, filters. Keys are compared structurally, so object property order does not matter, but every parameter that changes the response must be in the key.

Freshness policy. Each content type gets a staleTime that reflects how often it changes and how bad staleness is, the same reasoning as ISR windows on the server.

Validation. Query functions validate responses with a schema library before returning them, so a CMS model change surfaces as a typed error in one place instead of undefined deep inside a component.

Bash
# .env: the browser only ever sees the proxy
CMS_API_URL=https://cdn.contentful.com/spaces/abc123/environments/master
CMS_DELIVERY_TOKEN=cda_published_only
CMS_PREVIEW_TOKEN=cpa_drafts_server_only
NEXT_PUBLIC_CMS_PROXY=/api/cms
NEXT_PUBLIC_DEFAULT_STALE_MS=300000

Query keys and configuration

Setup hinges on query keys and timing. CMS endpoints return structured JSON with metadata, pagination cursors, and nested relations. Encode resource type, identifier, and parameters into a deterministic key array so the cache stays consistent and invalidation can target a single resource.

JavaScript
import { useQuery, QueryClient } from '@tanstack/react-query';

const fetchCMSResource = async (endpoint, params) => {
  const res = await fetch(`${endpoint}?${new URLSearchParams(params)}`);
  if (!res.ok) throw new Error(`CMS fetch failed: ${res.status}`);
  return res.json();
};

export function useArticle(id, locale = 'en-US') {
  return useQuery({
    queryKey: ['cms', 'articles', id, { locale }],
    queryFn: () => fetchCMSResource('/api/cms/articles', { id, locale }),
    staleTime: 1000 * 60 * 5, // 5 minutes
    gcTime: 1000 * 60 * 30,   // 30 minutes
    refetchOnWindowFocus: false,
    refetchOnReconnect: true,
  });
}

staleTime sets how long data stays fresh before a background refetch is eligible; a 5–15 minute window matches most publishing cadences without needless requests. refetchOnWindowFocus: false avoids hammering CMS rate limits during long sessions. gcTime should retain recently visited routes in memory so back-navigation is instant.

Stale-while-revalidate

Serving cached content immediately and refetching in the background keeps perceived performance high even when data has changed. A query moves through these states as staleTime and events drive it:

Query lifecycle states driven by staleTime and eventsA query fetches on first mount, becomes fresh, turns stale when staleTime elapses, refetches on a trigger or webhook invalidation, and is garbage-collected after gcTime without observers.First mountfetchingfreshstalerefetchingbackgroundgarbage collectedafter gcTimeresolvedstaleTimetriggerchangedno observers
Fresh data never refetches; stale data refetches only on a trigger, which is why staleTime is the main cost control.

This mirrors SWR Stale-While-Revalidate Patterns but gives finer control over retry logic, pagination merging, and invalidation triggers. On navigation, React Query serves the cached article instantly, validates against the API, and re-renders only if the payload changed — never blocking the render thread. It’s the HTTP caching model from the MDN HTTP Caching reference, applied at the component layer.

staleTime by content type

The default staleTime of zero treats every mount and every window focus as a reason to refetch. For CMS content that changes a few times a week, that is almost pure waste. A starting table, to be tuned from real revision history:

Content type staleTime gcTime Notes
Navigation, footer, settings 30 min 60 min Shared by every page; invalidate by webhook.
Articles, docs 5 to 15 min 30 min Readers rarely notice minutes of staleness.
Landing pages in a campaign 1 to 5 min 15 min Marketing edits cluster around launches.
Prices, availability 0 to 30 s 5 min Often better served by a dedicated API than the CMS.
Preview drafts 0 1 to 2 min Always refetch; discard quickly.

A useful mental model is that staleTime is the client-side equivalent of an ISR window, and like an ISR window it is a safety bound once push invalidation exists. With SSE or WebSocket invalidation wired in, you can lengthen staleTime substantially, because publishes no longer depend on it.

Server rendering and hydration

In Next.js, Remix or TanStack Start, prefetch the page’s primary queries on the server into a QueryClient created per request, dehydrate it and wrap the page in a HydrationBoundary. The client then renders from the hydrated cache and only refetches once the data becomes stale. Two rules keep this reliable. Create the server QueryClient per request, never at module scope, or data leaks between visitors. And build keys with the same factory on both sides, because a hydrated entry under a key the client never asks for is just wasted HTML. Set a non-zero staleTime on the client too, or every hydrated query refetches immediately on mount and the server work is thrown away.

Invalidation and editorial workflows

Published changes must propagate without manual cache busting. Combine webhook events with queryClient.invalidateQueries() in a centralized handler that maps CMS webhooks (Contentful, Sanity, Strapi) to targeted updates:

JavaScript
export const handleCMSWebhook = async (queryClient, payload) => {
  const { type, id, locale } = payload;

  // Invalidate the specific resource
  await queryClient.invalidateQueries({
    queryKey: ['cms', type, id, { locale }],
    refetchType: 'active',
  });

  // Invalidate list queries when pagination cursors shifted
  if (payload.isListUpdate) {
    await queryClient.invalidateQueries({
      queryKey: ['cms', `${type}-list`, { locale }],
    });
  }
};

For polling and exponential backoff during live-editing sessions, see React Query background refetch strategies for CMS. The TanStack Query docs cover invalidateQueries, refetchQueries, and setQueryData in full.

Invalidation reaches three places

A CMS publish has to reach the client cache, the server rendering cache and the CDN, in that order of difficulty. Server-side, the webhook calls revalidateTag or purges the CDN, as the ISR topic describes. Client-side, the webhook cannot reach open tabs directly, so the options are a short staleTime combined with focus refetch, polling during editing sessions, or a push channel (Server-Sent Events or a WebSocket) that forwards entry ids to the handleCMSWebhook function above. Push is the only option that updates every open tab within seconds without constant polling, and it is cheap to run, because the payload is a list of ids.

Invalidation also needs a hierarchy. With keys shaped as ["cms", type, params], invalidateQueries({ queryKey: ["cms", "article"] }) invalidates every article query, whatever its parameters, while ["cms", "article", { slug, locale }] targets one. Prefix matching is what makes a key factory worth its few lines of code.

Pushing a publish into open tabsThe CMS webhook reaches the server, which verifies it and forwards the entry type and id over a Server-Sent Events channel; each tab invalidates matching queries and refetches only the active ones.CMSWebhook routeBrowser tabCMS proxyentry.publish (signed)SSE {type: article, id, locale}invalidateQueriesrefetchType: activeGET /api/cms/articles?slug=fresh JSON
Only active queries refetch immediately; inactive ones are marked stale and refetch the next time a component mounts them.

Payload shaping

CMS responses carry redundant nested objects, draft states, and localization trees that bloat client memory. Flatten and normalize payloads before they hit the cache to avoid referential inequality and wasted re-renders. Add TypeScript generics for type inference across hooks and enforce schema validation at the fetch layer.

For GraphQL backends, weigh React Query against Apollo Client GraphQL Caching, which normalizes an entity store automatically — React Query stays optimal for REST/JSON given its smaller footprint and explicit cache boundaries. For field extraction and memory-efficient normalization, see Optimizing React Query for headless CMS payloads.

Schema & Content Modeling Considerations

React Query stores whatever the query function returns, so the shape of the CMS response becomes the shape of the cache. Two modeling decisions dominate cost.

Reference depth. REST CMS APIs resolve references through parameters such as Contentful’s include, Strapi’s populate and Directus’s fields. Deep population returns large payloads with the same author or category repeated in every item of a list. Keep population to what the view renders, and fetch shared entities, such as navigation or authors, with their own queries so they are cached once and invalidated independently.

Localization. Locale belongs in the key, always. If the CMS supports fallback locales, decide whether the fallback is resolved by the CMS (Contentful does this per field) or by your proxy, and include the resolved chain in the key when it can differ per user. Otherwise a German visitor with a Swiss fallback and one without can share a cache entry.

Rich text deserves special handling. Portable Text, Contentful rich text and Strapi blocks arrive as JSON trees that the client renders to React elements. Keep the raw tree in the cache and render it in the component, instead of storing pre-rendered HTML strings. Stored HTML is harder to validate, and it lets unsanitized markup into a dangerouslySetInnerHTML path.

Preview & Draft Workflow

Preview needs its own cache namespace. Add preview: true to the parameters object of every key when draft mode is active, and point the query function at a preview proxy route that uses the preview token server-side. Draft and published results then live side by side without ever overwriting each other, which matters when an editor opens a preview tab next to the live site in the same browser. The general rules for draft/publish state apply unchanged.

Live preview SDKs push field updates while the editor types. Apply them with queryClient.setQueryData on the preview key, not by invalidating, so the preview re-renders instantly without a network round trip. When the editor publishes, invalidate the published key so the live tab picks up the change through the normal path. Set staleTime: 0 and gcTime to a minute or two for preview queries: drafts change constantly, and there is no value in keeping them after the editor leaves.

Error Handling & Resilience

React Query retries failed queries three times with exponential backoff by default. For CMS data, tune the rule instead of the count: retry network errors, 429 and 5xx responses, and never retry 4xx validation errors or 404s, because repeating them only delays the error state.

TypeScript
import { QueryClient } from "@tanstack/react-query";

class CmsHttpError extends Error {
  constructor(public readonly status: number, public readonly retryAfterMs?: number) {
    super(`CMS responded ${status}`);
  }
}

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error instanceof CmsHttpError) {
          if (error.status === 404 || (error.status >= 400 && error.status < 429)) return false;
        }
        return failureCount < 3;
      },
      retryDelay: (attempt, error) =>
        error instanceof CmsHttpError && error.retryAfterMs ? error.retryAfterMs : Math.min(1000 * 2 ** attempt, 8000),
      staleTime: 5 * 60 * 1000,
    },
  },
});

When a background refetch fails, React Query keeps the previous data and sets error. That is the right default for content: show the cached article with a subtle notice instead of an error page. Reserve error boundaries (throwOnError) for the first load, when there is nothing to show.

Testing & Observability

Query hooks are easy to test with a fresh QueryClient per test and a mocked fetch layer. Mock Service Worker intercepts requests to your proxy, so the hooks run unchanged, and tests can assert on loading, success, retry and error states. The automated testing for headless integrations topic covers the fixtures, including recorded CMS responses that keep mocks honest when the content model changes.

What to test at each layer of a React Query integrationThe layers of a React Query CMS integration, what each test asserts, and the tool typically used.LayerAssertToolKey factorysame params give same key; locale changes keyunit testQuery functionschema validation rejects bad payloadsunit test + zodHooksloading, success, retry, stale data on errorTesting Library + MSWInvalidationwebhook ids hit exactly the right keysQueryClient spyProductionrefetch rate, error rate per key prefixQueryCache subscriber
Most regressions come from keys and validation, which are also the cheapest layers to test.

In production, subscribe to the QueryCache and report fetch durations and errors per key prefix to your monitoring. A spike in refetches for one content type usually means a staleTime that is too short, or a component that creates a new object in its key on every render, which makes every render a new query.

Production checklist

  1. Use query-key factories: centralize key generation to prevent collisions across locales, environments, and content types.
  2. Align staleTime with editorial cadence: 5–15 minutes based on publishing frequency, not arbitrary defaults.
  3. Invalidate by key, not in bulk: replace blanket clears with webhook-driven invalidateQueries scoped to specific resources.
  4. Normalize nested relations: cache reusable blocks (authors, categories, media) independently to cut duplication.
  5. Disable needless refetches: turn off refetchOnWindowFocus and refetchOnMount where they don’t earn their cost.
  6. Validate at runtime: use zod or io-ts inside queryFn to catch malformed CMS responses before they reach the UI.

Operational Checklist

  • A single key factory builds every key; lint rejects hand-written keys.
  • Browser code calls a CMS proxy; delivery and preview tokens stay on the server.
  • Every query function validates its response before returning it.
  • staleTime is set per content type through one wrapper, and preview uses zero.
  • Webhooks reach open tabs through a push channel that invalidates by key prefix.
  • Retries skip 4xx responses and honour Retry-After on 429.
  • Server rendering uses a per-request QueryClient and hydrates with the same keys.
  • Production monitoring reports fetch counts, errors and durations per key prefix.

Most teams adopt these in roughly that order, and each one removes a class of bugs rather than a single bug. The factory and the proxy come first because every later item depends on them: invalidation, preview isolation and hydration all assume that keys are consistent and that tokens never reach the browser. Monitoring comes last, but it is what shows whether the staleTime table matches real traffic. Revisit the table when it does not.

Frequently Asked Questions

When should I choose React Query over Apollo Client for CMS data?

Choose React Query for REST CMS APIs and for GraphQL APIs where each page fetches self-contained documents. Choose Apollo Client GraphQL caching when many queries share the same entities and you want updates to one entry reflected everywhere automatically.

Is staleTime or gcTime the setting that controls freshness?

staleTime controls freshness: how long data is served without any refetch. gcTime only controls how long unused data stays in memory. A long gcTime with a short staleTime gives instant back-navigation that still revalidates.

Do I still need React Query if my pages are server-rendered?

For static reading pages, often not: server rendering plus ISR delivers the content, and nothing on the client needs to refetch. React Query earns its place for interactive views such as search, filtering, infinite lists and preview, and for data that must stay fresh while a page is open.

How do I share the cache between server rendering and the client?

Prefetch on the server into a request-scoped QueryClient, pass dehydrate(queryClient) to a HydrationBoundary, and use identical keys on the client. The payload optimization guide covers the hydration mismatches that appear when keys differ.

How do I handle CMS rate limits with many open tabs?

Keep the browser away from the CMS entirely: the proxy can cache responses at the edge for a few seconds, which collapses identical requests from thousands of tabs into one origin request. Combined with push invalidation and a non-zero staleTime, the CMS then sees traffic proportional to publishes rather than to readers.

Should mutations from editors go through React Query too?

When the frontend writes to the CMS, as with comments, form submissions or an in-context editing tool, use useMutation with the management API behind a server route. On success, invalidate the affected keys through the factory, or apply an optimistic update with setQueryData and roll it back in onError.