Query-Key Factories for Localized CMS Content
Building on React Query for CMS Data, this guide writes one typed key factory that every hook and every invalidation call goes through, so localized, previewed and filtered CMS queries can never share a cache entry by accident and a webhook can target any level of the hierarchy.
Query keys are the cache’s identity system. React Query compares them structurally and invalidates by prefix, which is powerful and unforgiving: two keys that differ only in property spelling (locale versus lang) are different queries, and a key that forgets the locale merges English and German into one entry. Hand-written keys scattered across fifty components drift into exactly these bugs within a few months. A factory removes the drift by construction.
The Problem
A travel publisher runs its site in English, German and Swiss German on Contentful, with de-CH falling back to de-DE field by field. Hooks were written by different people over two years. Some keys include locale, some include lang, one includes the full Accept-Language header, and the destination hook forgot the locale entirely. The symptoms are all intermittent: a German visitor who first viewed a page in English sees English captions; the webhook handler invalidates ["cms", "destination", id] but the hook caches under ["destination", { id, locale }], so publishes never reach it; and the Swiss site shows German content even where a Swiss translation exists, because the cached German entry satisfied the Swiss query.
None of these is a React Query bug. They are identity bugs, and they are fixed in one place.
How Key Hierarchies Work
React Query matches keys by prefix when you invalidate, refetch or remove queries. With the key shape ["cms", kind, type, params], each level of the array is a scope:
["cms"]covers every CMS query, which is useful after a deploy that changes the content model.["cms", "entry", "article"]covers every article entry in every locale, for a template change or a bulk import.["cms", "entry", "article", { slug: "zermatt", locales: ["de-CH", "de-DE"] }]covers one entry in one resolved locale chain.["cms", "list", "article"]covers every article listing, regardless of filters, which is what a publish of a new article must invalidate.
The parameter object is compared deeply, so property order does not matter, but values do. That is why the factory stores the resolved fallback chain rather than the requested locale. Two visitors whose requests resolve to the same chain share an entry, and a visitor whose chain differs never does.
Implementation
The factory is a plain object of functions. It is typed so that every parameter object has a fixed shape, and it exposes two kinds of builder: exact keys for hooks, and prefixes for invalidation. The locale chain is resolved once, from the same configuration the CMS uses.
// lib/cms-keys.ts
export type Locale = "en-US" | "de-DE" | "de-CH";
export type ContentType = "article" | "destination" | "author" | "navigation";
const FALLBACKS: Record<Locale, Locale[]> = {
"en-US": ["en-US"],
"de-DE": ["de-DE", "en-US"],
"de-CH": ["de-CH", "de-DE", "en-US"],
};
export function localeChain(locale: Locale): Locale[] {
return FALLBACKS[locale];
}
interface EntryParams {
slug?: string;
id?: string;
locale: Locale;
preview?: boolean;
}
interface ListParams {
locale: Locale;
preview?: boolean;
filters?: Record<string, string | number | boolean>;
pageSize?: number;
}
export const cmsKeys = {
all: ["cms"] as const,
entries: (type: ContentType) => ["cms", "entry", type] as const,
entry: (type: ContentType, p: EntryParams) =>
[
"cms",
"entry",
type,
{ slug: p.slug, id: p.id, locales: localeChain(p.locale), preview: p.preview ?? false },
] as const,
lists: (type: ContentType) => ["cms", "list", type] as const,
list: (type: ContentType, p: ListParams) =>
[
"cms",
"list",
type,
{ locales: localeChain(p.locale), preview: p.preview ?? false, filters: p.filters ?? {}, pageSize: p.pageSize ?? 20 },
] as const,
};
// Invalidation helpers used by the webhook/SSE handler.
import type { QueryClient } from "@tanstack/react-query";
export async function onEntryChanged(qc: QueryClient, type: ContentType, id: string, isNew: boolean): Promise<void> {
if (!isNew) {
await qc.invalidateQueries({
queryKey: cmsKeys.entries(type),
// Match every locale and preview variant of this id, and slug-keyed variants too.
predicate: (q) => {
const params = q.queryKey[3] as { id?: string } | undefined;
return params?.id === id || q.state.data === undefined || hasEntryId(q.state.data, id);
},
});
}
await qc.invalidateQueries({ queryKey: cmsKeys.lists(type) });
}
function hasEntryId(data: unknown, id: string): boolean {
return typeof data === "object" && data !== null && (data as { sys?: { id?: string } }).sys?.id === id;
}
The predicate covers a subtle case. Pages usually fetch entries by slug, but webhooks identify entries by id. The predicate matches slug-keyed queries whose cached data carries the changed id, so the handler does not need a slug lookup. Hooks then use the factory and nothing else:
// hooks/use-destination.ts
import { useQuery } from "@tanstack/react-query";
import { cmsKeys, localeChain, type Locale } from "@/lib/cms-keys";
import { fetchEntryBySlug } from "@/lib/cms-fetch";
export function useDestination(slug: string, locale: Locale, preview = false) {
return useQuery({
queryKey: cmsKeys.entry("destination", { slug, locale, preview }),
queryFn: () => fetchEntryBySlug("destination", slug, localeChain(locale), preview),
staleTime: preview ? 0 : 10 * 60 * 1000,
});
}
Add a lint rule to enforce the factory. @tanstack/eslint-plugin-query checks that every variable used in the query function also appears in the key. A small custom rule, or a code review checklist item, bans array literals starting with "cms" outside cms-keys.ts.
Configuration Reference
| Factory member | Returns | Use it for |
|---|---|---|
cmsKeys.all |
["cms"] |
Full reset after a content model migration. |
cmsKeys.entries(type) |
["cms","entry",type] |
Invalidating one type’s entries, optionally with a predicate. |
cmsKeys.entry(type, p) |
exact key | useQuery, prefetchQuery, setQueryData. |
cmsKeys.lists(type) |
["cms","list",type] |
Invalidating every listing of a type after a publish. |
cmsKeys.list(type, p) |
exact key | useQuery and useInfiniteQuery for listings. |
localeChain(locale) |
resolved fallback array | Key parameters and the CMS request itself. |
Gotchas & Edge Cases
- Undefined versus missing properties.
{ slug: "a", id: undefined }and{ slug: "a" }hash identically in React Query, because keys are serialized with JSON semantics. That is convenient, but do not rely onnull, which is serialized and does create a distinct key. - Fallback chains that change. If editors add a locale or reorder fallbacks in the CMS, cached keys built with the old chain become unreachable. They age out through
gcTime, which is harmless, but invalidatecmsKeys.allafter such a change so open tabs refetch. - Filters with unstable objects. A filters object built inline in a component is a new object on every render. Structural comparison makes the key stable anyway, but memoize the object when it feeds other hooks, to avoid extra effect runs.
- Server and client factories diverging. Server prefetching and client hooks must import the same factory module. A copied factory inevitably drifts, and hydrated data then lands under keys the client never reads.
- Preview default. Default
previewtofalseexplicitly, as the factory does. Leaving it out of the key when false and adding it when true is equivalent in hashing, but explicit values make debugging in DevTools much clearer.
Verifying the Result
Open React Query DevTools and browse the site in two locales. Every CMS query should appear with the same four-segment shape, and the locales array should show the resolved chain. Then trigger a webhook for one entry and watch which queries flip to stale: only that entry’s variants and the lists of its type should change. A unit test can assert the same by seeding a QueryClient with a matrix of keys, calling onEntryChanged and checking isStale for each.
Migrating Existing Hooks
Existing codebases rarely start with a factory. Migrate incrementally: add the factory, then change one content type at a time, updating its hooks, its prefetches and the webhook mapping in the same pull request. During the migration, the webhook handler should invalidate both the old and the new key shapes for types still in transition, so publishes keep working. When the last hand-written key is gone, turn on the lint rule so none come back.
Frequently Asked Questions
Why store the resolved locale chain instead of the requested locale?
Because the chain decides the content. Two requested locales with the same chain produce identical responses and can share an entry, and one requested locale whose chain changes after a CMS configuration update must not reuse the old entry. The chain is the true identity.
Can I generate the factory from the CMS schema?
Partially. Code generators can emit the content type union and parameter types from the CMS schema, which keeps ContentType in sync with the model. The hierarchy and invalidation helpers are application design and should stay hand-written.
How should the factory handle environments and CMS spaces?
If one frontend reads from several spaces or environments, for example a shared “global” space for navigation and a regional space for content, make the space a key segment directly after the prefix: ["cms", "global", "entry", ...]. Invalidation by space then becomes a prefix match, and two spaces can never collide even when they reuse entry ids.
Does this work with useSuspenseQuery and prefetching on the server?
Yes. Every React Query API that accepts a queryKey accepts factory output, including prefetchQuery on the server and useSuspenseQuery on the client. That shared identity is the reason hydration works reliably with a factory.