Skip to content
qt

Not using TanStack Start?

Standalone SPA + Backend

query-tags works with any Node.js or Bun backend. Run your Express, Hono, Fastify, Elysia, or Koa server alongside a Vite SPA — no TanStack Start required.

ExpressHonoFastifyElysia (Bun)Koa
When to use this pattern
  • You already have an existing REST API in Express, Hono, Fastify, Elysia, or Koa
  • You prefer a separate SPA build (Vite) rather than a full-stack Start app
  • You're running a Bun server or another non-Node runtime
  • You want to add real-time cache invalidation to a project without migrating your framework

How it works

The backend mounts an adapter at /api/invalidator that exposes both an SSE stream (GET) and a control endpoint (POST). The SPA connects with createHTTPInvalidatorClient and receives invalidation events over SSE. When a mutation calls system.invalidateTags(…), every tagged query in the SPA refetches automatically.

SPA (Vite)→ GET /api/invalidator (SSE) →Backend→ invalidateTags() →SSE event → refetch
1

Install

Add the package to both your backend and frontend.

pnpm add @tanstack-tools/query-tags

The backend needs no extra peer deps — keyv is bundled. The frontend needs @tanstack/react-query, @tanstack/react-router, and react as peer deps.

2

Backend — mount the adapter

Create the tag invalidation system and mount the adapter. The only line that differs between frameworks is the adapter import.

import express from "express"
import cors from "cors"
import { createTagInvalidationSystem } from "@tanstack-tools/query-tags/server"
import { createExpressMiddleware } from "@tanstack-tools/query-tags/adapters/express"

const system = createTagInvalidationSystem()
const app = express()

app.use(cors())
app.use(express.json())

// Mount the invalidation API — handles both SSE stream and control endpoints
app.use("/api/invalidator", createExpressMiddleware(system))

// Call invalidateTags in any mutation handler
app.post("/api/todos", async (req, res) => {
  const todo = await db.createTodo(req.body)
  await system.invalidateTags({ tags: [["todos"]] })
  res.status(201).json(todo)
})

app.listen(3001)

Call system.invalidateTags({ tags: [...] }) in any mutation route and connected SPA clients will automatically refetch matching queries.

3

Frontend — Vite proxy (dev)

Proxy /api to the backend during development so the SPA and server share the same origin.

vite.config.tsTS
// vite.config.ts
import { defineConfig } from "vite"

export default defineConfig({
  server: {
    proxy: {
      "/api": {
        target: process.env.VITE_API_URL || "http://localhost:3001",
        changeOrigin: true,
      },
    },
  },
})
4

Frontend — create the invalidator client

Use createHTTPInvalidatorClient instead of the TanStack Start server-function variant.

src/lib/invalidator-client.tsTS
// src/lib/invalidator-client.ts
import { createHTTPInvalidatorClient } from "@tanstack-tools/query-tags/http-client"

export const invalidatorClient = createHTTPInvalidatorClient({
  basePath: "/api/invalidator",
})

Import from @tanstack-tools/query-tags/http-client, not from /client — the /client entry requires TanStack Start server functions.

5

Frontend — wrap with the provider

Add TagInvalidationProvider in your root route. It opens the SSE connection and watches active queries for matching tags.

src/routes/__root.tsxTSX
// src/routes/__root.tsx
import { Outlet, createRootRoute, useRouter } from "@tanstack/react-router"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { TagInvalidationProvider } from "@tanstack-tools/query-tags/react"
import { invalidatorClient } from "../lib/invalidator-client"

const queryClient = new QueryClient()

export const Route = createRootRoute({ component: Root })

function Root() {
  const router = useRouter()
  return (
    <QueryClientProvider client={queryClient}>
      <TagInvalidationProvider
        client={invalidatorClient}
        queryClient={queryClient}
        router={router}
      >
        <Outlet />
      </TagInvalidationProvider>
    </QueryClientProvider>
  )
}
6

Tag your queries

Use defineTags to create a typed tag tree, then attach tags to queries via withTags or meta.tags. The provider picks them up automatically.

// src/lib/tags.ts
import { defineTags } from "@tanstack-tools/query-tags"

export const appTags = defineTags({
  todos: {
    list: () => ["todos"],
    byId: (id: number) => ["todo", id],
  },
})

// src/routes/index.tsx
import { useQuery } from "@tanstack/react-query"
import { withTags } from "@tanstack-tools/query-tags"
import { appTags } from "../lib/tags"

export function useTodos() {
  return useQuery(
    withTags(
      { queryKey: ["todos"], queryFn: fetchTodos },
      [appTags.todos.list()],
    ),
  )
}
Runnable examples

The repository contains a ready-to-run SPA frontend that works with all five backends:

Start any backend with pnpm --filter @tanstack-tools/example-server-express dev, then run the SPA with pnpm --filter @tanstack-tools/example-spa dev.