This episode covers production-level TanStack Query architecture for team scale: query module folder structure, query key catalogs, domain-specific custom hooks, naming conventions, monitoring, bundle size, SSR tuning, and a caching and invalidation runbook.

Writing a query that works for one component is easy. Keeping the cache consistent in a large application worked on by many developers is another story. Without conventions, query keys become a mess, queryFn is duplicated in three places, and invalidation silently shoots the wrong keys.
Episode 21 covers a production-ready architecture: query module folder structure, a centralized query key catalog, domain-specific custom query hooks, down to monitoring, bundle size, SSR tuning, and a caching runbook a team can use. This is the episode for leveling up from "queries that work" to "a server state system that can be maintained for years".
The first rule: never write fetch inside a component. Separate the data access layer from the presentation layer with a clear folder structure:
mkdir -p src/api src/features/todos src/features/users
mkdir -p src/query/keys src/query/hooksA commonly used pattern:
src/api/ hosts pure query functions — only fetch and response parsing.src/query/keys/ hosts the centralized query key catalog.src/query/hooks/ hosts domain-specific custom hooks.src/features/<domain>/ hosts the UI components that use those hooks.api/ must not use TanStack Query at all — the functions there take arguments and return Promises. fetchTodos(id) and createTodo(payload) are examples of query functions that can be tested without rendering a component. This layer can also be shared with backend developers or generated from OpenAPI.
Function names follow a pattern everyone can reason about: fetchX for reads, createX and updateX for writes, deleteX for deletions. Custom hooks are named by domain: useTodos, useTodoById, useCreateTodo. Query keys follow the hierarchy of domain, then resource, then modification:
export const todoKeys = {
all: ["todos"] as const,
lists: () => [...todoKeys.all, "list"] as const,
list: (filters) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, "detail"] as const,
detail: (id) => [...todoKeys.details(), id] as const,
}The todoKeys catalog makes the whole application use the same keys. todoKeys.detail(id) produces an identical array in the hook, the prefetch, and the invalidation. No more manually typing the string "todos" in three different files with the risk of typos.
A per-domain hook wraps useQuery and useMutation with that domain's default options. Components never interact directly with TanStack Query — they just call the hook:
export function useTodos(filters) {
return useQuery({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
staleTime: 5 * 60 * 1000,
})
}
export function useCreateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: todoKeys.all })
},
})
}The benefits are big. If the team decides to raise staleTime for all todos, you only need to change one place. If the endpoint changes, it's only in api/. Components become thin and focused on presentation — useTodos(filters) and useCreateTodo() are the only APIs they know.
In production, you need visibility: how many queries are refetched, how many fail, and whether the cache is growing out of control. QueryCache provides getAll() to peek at all active queries:
export function reportQueryCache(client) {
const queries = client.getQueryCache().getAll()
return queries.map((query) => ({
key: query.queryKey,
state: query.state.status,
observers: query.getObserversCount(),
}))
}getAll() returns the list of queries along with their statuses. Combine it with error tracking (for example Sentry) to send reports when a query fails repeatedly, and with network logs to spot abnormal refetch patterns. This data helps the team find a staleTime that is too small or invalidation that is too aggressive.
TanStack Query v5 is designed to be tree-shakeable: only the imports you use end up in the bundle. Use named imports, never import the whole package. Run a bundle analyzer periodically and watch the @tanstack/query-core size in the report. Avoid loading devtools in production:
npm i -D vite-plugin-analyzer
npx vite build --mode analyzeAlso pay attention to this line in the main component — make sure devtools only render in development, because @tanstack/react-query-devtools adds unnecessary weight to the production build.
For Next.js or Remix applications, tune SSR with three rules: create a new QueryClient per request on the server, prefetch with prefetchQuery, then dehydrate and hydrate via HydrationBoundary. The first rule is the one most often broken — a QueryClient shared between requests will leak user A's data to user B.
After hydration, data prefetched on the server is considered fresh thanks to staleTime. Don't set staleTime to 0 for server-rendered pages, because the UI will trigger a pointless refetch as soon as the page is visible.
A runbook is a team document containing standard patterns: when to use invalidateQueries, when to use setQueryData, when to use refetchInterval, and how to handle edge cases. Example contents:
setQueryData directly, validate in onError.invalidateQueries with a narrow scope.refetchInterval of 5 seconds, disabled when the tab isn't focused.A runbook prevents every developer from independently reinventing a different way to solve the same problem. With a key catalog, custom hooks, and a consistent runbook, a server state architecture can be maintained by anyone on the team without retraining.
Episode 21 united all the previous episodes into a single system: a tidy query module folder structure, a centralized query key catalog, domain-specific custom hooks, cache monitoring, controlled bundle size, and a runbook that makes the team work with one pattern. This is the architecture that survives from a demo up to thousands of requests per minute.
Key takeaways:
api/, query/keys/, and query/hooks/.getQueryCache().getAll() gives visibility into cache status.In episode 22, the final episode of the series, we will discuss alternative ecosystems and final reflection — a comparison of TanStack Query with SWR, RTK Query, Apollo, and manual useEffect, plus a recap of your journey from episodes 0 to 21.