FSD + TanStack Query + Next.js App Router: Managing queryKey and revalidateTag as a Single Source of Truth in entities
Have you ever experienced a situation where a user updated their profile, but the list screen still showed the old name? I once spent quite a long time tracking down this bug. The cause was simple: revalidateTag('users') had cleared the server cache, but TanStack Query's client cache was still alive. On top of that, one person had defined the queryKey in entities/user/api, while another teammate had written the same key as a string literal inside features/user-edit/ui, so invalidation was only being applied to the wrong scope.
This article is for those who have already adopted FSD and are using TanStack Query v5 together with Next.js App Router. If FSD itself is new to you, it's worth reading the FSD official documentation first. Here, the focus is less on "how do I set this up?" and more on "what structure does the whole team need to stay consistent?"
Define queryKey in entities, manage revalidateTag strings as constants in shared, and have features combine the two to simultaneously invalidate server and client caches. Apply this pattern as a team convention, and you'll find that "this key appears here again" comments disappear from PR reviews.
Core Concepts
FSD Layers — Dividing the World into Nouns and Verbs
The heart of FSD is unidirectional dependency between layers. Higher layers can import from lower layers, but not the other way around.
app/ → pages/ → widgets/ → features/ → entities/ → shared/When you first encounter FSD, distinguishing between entities and features is the trickiest part. I spent a long time debating "is this an entity or a feature?" with teammates, and ultimately the clearest criterion was noun vs. verb.
| entities | features | |
|---|---|---|
| Nature | Business concept (noun) | User action (verb) |
| Examples | User, Post, Comment | login, add-to-cart, edit-profile |
| Owned data | Type definitions, basic CRUD, queryOptions | Complex UI interactions, mutations |
| Reuse scope | Referenced across all layers | Reused across multiple pages |
UserCard is an entity — it's the concept of displaying a user. UserEditForm is a feature — it contains the action of a user editing their profile.
Unidirectional dependency rule: features can import from entities, but entities must never import from features. This single rule is what makes the entire FSD architecture predictable.
queryKey — The Address of a Cache and the Basis for Invalidation
In TanStack Query, queryKey is not just a string array. It is the address for locating a cache, and a hierarchical identifier that determines which scope to invalidate.
['users'] // all user-related caches
['users', 'list'] // the entire user list
['users', 'list', { status: 'active' }] // filtered list
['users', 'detail', userId] // a specific user's detailThe queryOptions() helper introduced in TanStack Query v5 elevates this pattern further. Unlike the older approach of managing keys and functions separately, it bundles both into a single type-safe object that can be shared across many places. This structure is called the query factory pattern, and this file serves as the Single Source of Truth for all queries for that entity.
// Before v5 — key and fn are separated, leading to duplication
const userDetailKey = (id: string) => ['users', 'detail', id]
const { data } = useQuery({
queryKey: userDetailKey(id),
queryFn: () => fetchUser(id),
})
// After v5 — bundled with queryOptions for reuse
const userDetailOptions = (id: string) =>
queryOptions({
queryKey: ['users', 'detail', id],
queryFn: () => fetchUser(id),
staleTime: 10 * 60 * 1000, // considered fresh for 10 minutes
})
// The same object can be reused in multiple places
const { data } = useQuery(userDetailOptions(id))
await queryClient.prefetchQuery(userDetailOptions(id))
queryClient.getQueryData(userDetailOptions(id).queryKey) // type-safestaleTime is the duration for which TanStack Query considers cached data "fresh." Once this time has passed, a background refetch will occur on the next render. Unlike revalidateTag, it does not call the server immediately — it is managed only in client memory.
revalidateTag — Managing Two Layers of Cache Together
When using Next.js App Router, the cache is split into two layers: the server's fetch cache (data read by RSC) and the browser's TanStack Query cache (data read by client components). If you don't invalidate both at the same time, one side ends up showing fresh data while the other shows stale data.
// Server Component — fetch with a tag attached
const users = await fetch('/api/users', {
next: { tags: ['users'] }
})
// Server Action — invalidate by tag when data changes
'use server'
async function updateUser(id: string, data: UpdateUserDto) {
await api.updateUser(id, data)
revalidateTag('users') // Invalidate Next.js server cache
}| revalidateTag | invalidateQueries | |
|---|---|---|
| Execution environment | Server (Server Actions, Route Handlers) | Client |
| Invalidation target | Next.js fetch cache (RSC) | TanStack Query in-memory cache |
| When to use | Data read by Server Components | Data loaded via useQuery |
| Concurrent use | Both can be called within a Server Action | — |
The problem is that revalidateTag('users') and queryKey: ['users'] tend to scatter as separate string constants. If you miss one place when renaming a tag, the client shows fresh data while the server component serves stale cached data — or vice versa.
Practical Application
The three examples that follow build on each other in sequence. Example 1 establishes the foundation in entities, Example 2 adds server cache constants in shared, and Example 3 has features connect the two. Example 4 is an advanced option you can apply selectively as your team grows.
Example 1: Creating a Single Source of Truth in the entities Layer
entities are responsible for the shape of data and basic retrieval logic. It is natural to define queryOptions here as well. When features need to fetch user data, they simply import the object exported from this file.
// entities/user/api/queries.ts
import { queryOptions } from '@tanstack/react-query'
import { fetchUser, fetchUsers } from './fetch'
import type { UserFilters } from '../model/types'
// userKeys is an internal implementation — not exposed directly to the outside
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters?: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
export const userQueries = {
list: (filters?: UserFilters) =>
queryOptions({
queryKey: userKeys.list(filters),
queryFn: () => fetchUsers(filters),
staleTime: 5 * 60 * 1000,
}),
detail: (id: string) =>
queryOptions({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
staleTime: 10 * 60 * 1000,
}),
}
// Explicitly expose the key prefix to be used in invalidateQueries
export const userQueryKeys = userKeys// entities/user/index.ts — expose only the public API to the outside
export { userQueries, userQueryKeys } from './api/queries'
export type { User, UserFilters } from './model/types'
export { UserCard } from './ui/UserCard'
export { UserAvatar } from './ui/UserAvatar'
// Internal implementation of fetch.ts is not exportedThe reason userQueryKeys is exposed separately is that passing a queryOptions object directly to invalidateQueries can cause type issues. Since v5's invalidateQueries accepts the InvalidateQueryFilters type, the recommended approach is to extract .queryKey as shown below.
// ✅ Recommended: explicitly extract queryKey before use
queryClient.invalidateQueries({ queryKey: userQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: userQueryKeys.detail(userId) })
// ❌ Caution: passing a queryOptions object directly where InvalidateQueryFilters is expected may cause a type error
queryClient.invalidateQueries(userQueries.list())Example 2: Sharing revalidateTag Constants from shared/config
In Example 1 we centralized queryKeys in entities; now we collect server cache tag strings in the shared layer. When the string passed to revalidateTag and the tag attached to fetch both reference the same constant, renaming a tag later requires modifying only one file.
// shared/config/cache-tags.ts
export const CACHE_TAGS = {
USERS: 'users',
USER_DETAIL: (id: string) => `user-${id}`,
POSTS: 'posts',
POST_DETAIL: (id: string) => `post-${id}`,
} as const// entities/user/api/fetch.ts — fetch functions for Server Components
// Note: in a real project, if there is an API client abstraction in shared/api,
// using that would better align with FSD principles. Here we use fetch directly for clarity.
import { CACHE_TAGS } from '@/shared/config/cache-tags'
export async function fetchUsersWithCache() {
const res = await fetch('/api/users', {
next: { tags: [CACHE_TAGS.USERS] }, // constant reference
})
return res.json()
}
export async function fetchUserWithCache(id: string) {
const res = await fetch(`/api/users/${id}`, {
next: { tags: [CACHE_TAGS.USERS, CACHE_TAGS.USER_DETAIL(id)] },
})
return res.json()
}This way, the single constant CACHE_TAGS.USERS is used identically on both sides: the tag attached to fetch and the revalidateTag call.
Example 3: Simultaneously Invalidating Server and Client Caches in the features Layer
When our team first adopted this pattern, the most common mistake was calling only revalidateTag or only invalidateQueries. Doing only one will always leave stale data on one side of the UI.
// features/user-edit/api/mutations.ts
'use server'
import { revalidateTag } from 'next/cache'
import { CACHE_TAGS } from '@/shared/config/cache-tags'
import { apiClient } from '@/shared/api/client'
import type { UpdateUserDto } from '@/entities/user'
export async function updateUserAction(id: string, data: UpdateUserDto) {
const updated = await apiClient.patch(`/users/${id}`, data)
// Invalidate server cache — using the CACHE_TAGS constant from Example 2
revalidateTag(CACHE_TAGS.USERS)
revalidateTag(CACHE_TAGS.USER_DETAIL(id))
return updated
}// features/user-edit/ui/UserEditForm.tsx
'use client'
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { userQueryKeys } from '@/entities/user' // key prefix exposed in Example 1
import { updateUserAction } from '../api/mutations'
import type { UpdateUserDto } from '@/entities/user'
interface Props {
userId: string
defaultValues?: { name: string; email: string }
}
export function UserEditForm({ userId, defaultValues }: Props) {
const queryClient = useQueryClient()
const [name, setName] = useState(defaultValues?.name ?? '')
const [email, setEmail] = useState(defaultValues?.email ?? '')
const mutation = useMutation({
mutationFn: (data: UpdateUserDto) => updateUserAction(userId, data),
onSuccess: () => {
// Invalidate client cache — using userQueryKeys exposed in Example 1
queryClient.invalidateQueries({ queryKey: userQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: userQueryKeys.detail(userId) })
},
onError: () => {
// On error, the cache is left untouched — existing data is preserved
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
mutation.mutate({ name, email })
}}
>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</button>
</form>
)
}features/user-edit/
api/
mutations.ts # Server Action — calls revalidateTag
ui/
UserEditForm.tsx # Client component — calls invalidateQueries
model/
schema.ts # Zod validation schema
index.tsOne practical caveat: when a Server Action completes, Next.js RSC re-renders, and at the same time invalidateQueries in onSuccess is also called. If these two operations race against each other, you may see the client component flicker once with stale data. In such cases, you can consider waiting for the RSC re-render to finish before triggering the client-side invalidation.
Example 4 (Advanced Option): Strengthening Type Safety with @lukemorales/query-key-factory
As the team grows or the number of entities increases, you can use createQueryKeys for more declarative management. If Examples 1–3 work well enough, there is no need to add another library — but the _def pattern for invalidating an entire group regardless of filter conditions can be very useful when you need it.
// entities/post/api/queries.ts
import { createQueryKeys } from '@lukemorales/query-key-factory'
import { fetchPosts, fetchPost } from './fetch'
import type { PostFilters } from '../model/types'
export const postKeys = createQueryKeys('posts', {
all: null,
list: (filters: PostFilters) => ({
queryKey: [filters],
queryFn: () => fetchPosts(filters),
}),
detail: (id: string) => ({
queryKey: [id],
queryFn: () => fetchPost(id),
}),
})// features/post-publish/ui/PostPublishButton.tsx
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { postKeys } from '@/entities/post'
import { publishPostAction } from '../api/mutations'
export function PostPublishButton({ postId }: { postId: string }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: () => publishPostAction(postId),
onSuccess: () => {
// Using _def invalidates all list queries regardless of filter conditions
queryClient.invalidateQueries({ queryKey: postKeys.list._def })
queryClient.invalidateQueries({ queryKey: postKeys.detail(postId).queryKey })
},
onError: () => {
// On error, the cache is left untouched
},
})
return (
<button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? 'Publishing...' : 'Publish'}
</button>
)
}The
_defproperty: The_defgenerated by@lukemorales/query-key-factoryis the base key for that query group.postKeys.list._defrepresents['posts', 'list'], and passing it toinvalidateQueriesinvalidates all list queries regardless of filter conditions.
Pros and Cons
Advantages
| Item | Description |
|---|---|
| Single Source of Truth | Since queryOptions is only defined in entities, key duplication and mismatches are prevented at the source |
| Precise invalidation | The hierarchical key structure allows you to selectively invalidate an entire list or just a single detail |
| Server-client synchronization | A single CACHE_TAGS constant ties together revalidateTag and invalidateQueries, preventing cache inconsistency |
| Type safety | Passing the .queryKey from a queryOptions return value directly lets TypeScript infer the key structure |
| Refactoring safety | When changing the key structure, only the internals of entities need to be modified — everything else follows automatically |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Initial learning curve | There are many ambiguous cases when judging the entities/features boundary | Documenting "noun → entity, verb → feature" as a team convention helps |
| Placement of composite queries | It is unclear which layer a query fetching both user + post belongs to | Placing it in the api segment of widgets or pages is recommended |
| Overhead for small projects | The increasing number of files means upfront setup cost | For teams under 5 or projects under 10 pages, a simpler structure is sufficient |
| Missing dual cache invalidation | Calling only revalidateTag or only invalidateQueries leaves one cache stale |
Adding a Server Action checklist to the PR template helps |
| RSC-client race condition | RSC re-rendering and the invalidateQueries call may fire concurrently after a Server Action completes |
If flickering occurs, consider triggering client-side invalidation after RSC re-rendering has finished |
The Most Common Mistakes in Practice
-
Exporting
userKeysdirectly fromindex.ts— Once features start manipulating keys directly, changing the internal structure of entities requires updating features as well, creating tight coupling. It is recommended to export only what is intentionally meant to be public, such asuserQueryKeys. -
Using literal strings directly in
revalidateTag— Hardcoding strings likerevalidateTag('users')means the type system cannot catch the moment the fetch tag and the invalidation tag fall out of sync. Routing through theCACHE_TAGSconstant eliminates this problem. -
Trying to call
invalidateQueriesinside a Server Action — Server Actions execute on the server and therefore have no access toqueryClient.invalidateQueriesmust always be called from the client component'sonSuccessoronSettled.
Closing Thoughts
The core principles of this pattern can be summarized in three lines:
- entities are responsible for the shape of queryKeys.
userQueryKeysanduserQueriesplay that role. - shared is responsible for the server cache tag names. A single
CACHE_TAGSconstant ties together both fetch tags andrevalidateTagcalls. - features are responsible for executing invalidation. The server cache is cleared in the Server Action, and the client cache is cleared in
onSuccess— both together.
Once these three are in place, you will spend more time thinking "which entity does this feature touch?" than debugging "why didn't the cache invalidate?"
Here is what I did first when introducing this structure to my team:
-
You can start by creating a single
shared/config/cache-tags.tsfile. Gather the strings currently used inrevalidateTagcalls throughout your codebase, organize them into aCACHE_TAGSconstant object, and verify they match the tag strings attached to your fetch calls. -
Pick one of your most-used entities and introduce a
queryOptionsfactory inqueries.ts. Migrate to theuserQueries.list(),userQueries.detail(id)form, then wire upuseQueryandinvalidateQueriesto reference the same keys. -
Select one Server Action and apply the
revalidateTag+onSuccess: invalidateQueriespattern. After modifying data, verify in DevTools that both the Server Component and the client component update at the same time — you will immediately feel how cleanly the dual cache invalidation strategy works.
References
- Usage with TanStack Query | Feature-Sliced Design
- Usage with React Query | Feature-Sliced Design (official)
- Layers Reference | Feature-Sliced Design
- queryOptions API Reference | TanStack Query v5
- Query Invalidation | TanStack Query v5 Docs
- @lukemorales/query-key-factory - GitHub
- Functions: revalidateTag | Next.js official docs
- TanStack Query integration | next-safe-action