Skip to content
qt

Migration guide

Add query-tags to an existing app

You don't need to rewrite anything. Tag queries gradually, replace invalidateQueries calls at your own pace, and the two approaches coexist safely during transition.

IncrementalZero breaking changesTanStack Start

Core principle

Gradual adoption — no big bang required

Untagged queries stay unchanged

Queries without meta.tags are invisible to query-tags. They keep working exactly as before — manual invalidateQueries and all.

Tag one module at a time

Pick the noisiest module (the one with the most manual invalidateQueries calls) and start there. Everything else is untouched.

Remove client-side invalidation last

Once a server function calls invalidateTags(), the matching client invalidateQueries() calls become redundant — remove them at your own pace.

Gradual rollout example

Tagged and untagged queries live side-by-side with no conflicts:

// You can tag queries one file at a time — untagged queries keep working exactly as before.
// There is no "all or nothing" switch. Roll out at your own pace:

// Week 1: tag the todos module only
export function useTodos() {
  return useQuery({
    queryKey: ["todos"],
    queryFn:  fetchTodos,
    meta: { tags: [appTags.todos.list()] },  // ✅ tagged
  })
}

// Week 2: still works fine — this query will NOT get tag invalidation yet
export function useUserProfile(id: string) {
  return useQuery({
    queryKey: ["user", id],
    queryFn:  () => fetchUser(id),
    // no meta.tags — untouched, uses manual invalidateQueries until you're ready
  })
}

Setup

One-time infrastructure setup

These five steps wire up the server system and client provider. You only do this once — after that, migrating individual queries is just adding meta.tags.

Step-by-step

Migrate a query in three moves

1

Add meta.tags to the query

One line. The query is now reactive — it will refetch whenever a matching tag is invalidated from the server.

Before

// BEFORE — plain TanStack Query (no tags, nothing to change)
export function useTodos() {
  return useQuery({
    queryKey: ["todos"],
    queryFn:  fetchTodos,
  })
}

After

// AFTER — add one line to opt this query into tag invalidation
import { appTags } from "#/lib/tag-contract"

export function useTodos() {
  return useQuery({
    queryKey: ["todos"],
    queryFn:  fetchTodos,
    meta: { tags: [appTags.todos.list()] },  // <-- new
  })
}

Prefer a query factory pattern? Use withTags(options, tags) — it returns a plain options object you can pass straight to useQuery.

// Alternative: withTags() wraps the whole options object
import { withTags } from "@tanstack-tools/query-tags"
import { appTags } from "#/lib/tag-contract"

export const todosQuery = withTags(
  { queryKey: ["todos"], queryFn: fetchTodos },
  [appTags.todos.list()],
)

// Then: useQuery(todosQuery)
// Good for shared query factories — tags travel with the options object.
2

Add invalidateTags to the server function

Move the invalidation logic to the server. The call fires after the mutation succeeds and broadcasts an SSE event to every connected client.

Before

// BEFORE — manual invalidateQueries on the client
const addTodo = useMutation({
  mutationFn: async (name: string) => {
    await createTodoServerFn({ name })
  },
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["todos"] })
    queryClient.invalidateQueries({ queryKey: ["todos", "summary"] })
    // Easy to miss one. Doesn't work cross-tab.
  },
})

After

// AFTER — invalidateTags() lives on the server, inside the server function
import { createServerFn } from "@tanstack/react-start"
import { tagInvalidation } from "#/lib/tag-invalidation"
import { appTags } from "#/lib/tag-contract"
import { z } from "zod"

export const addTodoServerFn = createServerFn()
  .validator(z.object({ name: z.string().min(1) }))
  .handler(async ({ data }) => {
    await db.insert(todos).values({ name: data.name })

    // Invalidates list, summary, and all byId queries in one call.
    // Works across every connected browser tab automatically.
    await tagInvalidation.invalidateTags({
      tags: [appTags.todos()],
    })
  })

// The mutation no longer needs onSuccess — nothing to do on the client.
const addTodo = useMutation({
  mutationFn: (name: string) => addTodoServerFn({ data: { name } }),
})

appTags.todos() is the group tag — it expands to todos.list, todos.summary, and all todos.byId(*) entries in one call. No need to enumerate each variant.

3

Remove the client-side onSuccess handler

Once the server calls invalidateTags(), the onSuccess: () => queryClient.invalidateQueries(…) in your mutation is redundant. Delete it. The mutation becomes a simple fire-and-forget.

Don't remove the onSuccess handler until the server function is calling invalidateTags(). Running both at once causes a harmless double-invalidation but is safe as a transitional state.

Testing

Testing during migration

The /testing entry point ships mock utilities so you can verify tag invalidation in unit and component tests without a real SSE connection.

// In tests — use createMockInvalidationSystem from /testing
import { createMockInvalidationSystem } from "@tanstack-tools/query-tags/testing"
import { renderWithClient } from "./test-utils"

it("adds a todo and invalidates the list", async () => {
  const mock = createMockInvalidationSystem()

  // Render with the mock provider
  renderWithClient(<TodoList />, { tagSystem: mock })

  await userEvent.click(screen.getByRole("button", { name: /add/i }))
  await userEvent.type(screen.getByRole("textbox"), "Buy milk")
  await userEvent.click(screen.getByRole("button", { name: /submit/i }))

  // Assert that the right tags were invalidated
  expect(mock.getInvalidatedTags()).toContainEqual(["todos"])
})

createMockInvalidationSystem returns a test double with a getInvalidatedTags() method. Import from @tanstack-tools/query-tags/testing — it requires Vitest's vi and must only be imported in test files.

Migration checklist

  • Install @tanstack-tools/query-tags and keyv
  • Create src/lib/tag-contract.ts with defineTags()
  • Create src/lib/tag-invalidation.ts with createTagInvalidationSystem()
  • Add the /api/invalidator/$ API route
  • Wrap your root layout with TagInvalidationProvider
  • Tag the first module: add meta.tags (or withTags) to existing useQuery calls
  • Update the matching server function(s) to call invalidateTags()
  • Verify the query refetches automatically (watch dataUpdatedAt)
  • Remove the now-redundant onSuccess invalidateQueries calls
  • Repeat for the next module at your own pace

Live demos

See it in action

The interactive demos show every concept from this guide working end-to-end — tagged queries, server invalidation, multi-tab sync, and more.