Skip to Content
GuidesTanStack Query

TanStack Query Integration

@routar/react-query derives typed queryOptions and mutationOptions factories directly from your routar router. No new hook API — use TanStack’s own hooks as-is.

Installation

npm install @routar/react-query @tanstack/react-query

@tanstack/react-query@^5 is a peer dependency. @routar/core is a regular dependency — installed automatically.

createQueries

Pass your createApi client to createQueries — the router is recovered from the client automatically, so you never pass it twice. The result mirrors the shape of your router — every GET endpoint becomes a query accessor, every non-GET endpoint becomes a mutation accessor.

// remote/services/todo.ts (same file as the createApi client) import { createQueries } from '@routar/react-query' import { todoApi } from './todo' export const todoQuery = createQueries(todoApi)

Per-endpoint defaults

Pass a defaults map to createQueries to set default options for specific endpoints. Each key is an endpoint name; the value is merged before per-call options (per-call always wins).

export const todoQuery = createQueries(todoApi, { defaults: { getDetail: { staleTime: 5 * 60_000 }, getList: { staleTime: 60_000 }, }, })

Nested routers are supported — the map mirrors the router shape (e.g. { users: { getPosts: { ... } } }). For mutation endpoints, the value is any mutation option including invalidates; mutationFn and mutationKey are still set by the library. A call-site option always wins over the default.

If defaults.invalidates needs to reference a sibling query key, pass a factory function instead of a plain object — it receives the same queries object that createQueries returns, so key helpers are available without circular-variable issues:

export const todoQuery = createQueries(todoApi, (q) => ({ defaults: { create: { invalidates: [q.getList.queryKey()] }, update: { invalidates: [q.getList.queryKey(), q.getDetail.queryKey()] }, }, }))

The factory receives a preliminary q built without defaults/infinite applied — use it only for key helpers (.queryKey(), .mutationKey, .$key). For everything else, use the plain-object form.

Error typing

error in query/mutation results is typed as TanStack’s DefaultError. To narrow it to HttpError globally, augment TanStack’s Register interface once in your project — no change to createQueries is needed:

import type { HttpError } from '@routar/core' declare module '@tanstack/react-query' { interface Register { defaultError: HttpError } }

Queries

Each query accessor is a function that returns a TanStack queryOptions object — pass it directly to useSuspenseQuery, useQuery, prefetchQuery, or any other TanStack helper.

// params: the routar request ({ path?, query?, body? }) // options: any useQuery option except queryKey / queryFn todoQuery.getList(params?, options?) todoQuery.getDetail({ path: { id } }) // required when path params are present todoQuery.getList({ query: { done: true } }, { staleTime: 60_000 })
'use client' import { useSuspenseQuery, useSuspenseQueries } from '@tanstack/react-query' import { todoQuery } from '@/remote/services/todo' import { userQuery } from '@/remote/services/user' // Single query export function TodoList() { const { data } = useSuspenseQuery(todoQuery.getList()) return <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul> } // Multiple queries at once export function Dashboard() { const [todos, user] = useSuspenseQueries({ queries: [todoQuery.getList(), userQuery.getMe()], }) // todos.data and user.data are always non-nullable }

Key helper

Each query accessor exposes a .queryKey() helper that returns the same branded key used internally — useful for getQueryData, setQueryData, and invalidateQueries:

const key = todoQuery.getDetail.queryKey({ path: { id: '1' } }) // key is branded → qc.getQueryData(key) infers the correct return type qc.invalidateQueries({ queryKey: todoQuery.getList.queryKey() })

SSR prefetch

// app/(pages)/todos/page.tsx (Next.js server component) import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { Suspense } from 'react' import { getQueryClient } from '@/utils/get-query-client' import { todoQuery } from '@/remote/services/todo' export default async function TodosPage() { const qc = getQueryClient() await qc.prefetchQuery(todoQuery.getList()) return ( <HydrationBoundary state={dehydrate(qc)}> <Suspense fallback={<p>Loading…</p>}> <TodoList /> </Suspense> </HydrationBoundary> ) }

Infinite queries

Every GET query accessor has an .infinite member that returns a native TanStack infiniteQueryOptions object — pass it directly to useInfiniteQuery, useSuspenseInfiniteQuery, or prefetchInfiniteQuery.

Declare the pagination contract once in createQueries({ infinite }), keyed by endpoint name. The call site then only needs the base params (page-independent).

// remote/services/todo.ts — declare the contract once export const todoQuery = createQueries(todoApi, { infinite: { getList: { initialPageParam: 1, getNextPageParam: (lastPage, allPages) => lastPage.length === 10 ? allPages.length + 1 : undefined, pageParam: (page) => ({ query: { _page: page } }), // maps page → partial request }, }, })
'use client' import { useSuspenseInfiniteQuery } from '@tanstack/react-query' import { todoQuery } from '@/remote/services/todo' export function InfiniteTodoList() { const { data, fetchNextPage, hasNextPage } = useSuspenseInfiniteQuery( todoQuery.getList.infinite({ query: { _limit: 10 } }), // base params only ) // data: InfiniteData<TodoItem[], number> — each page is the endpoint's response (adapter applied) return ( <> {data.pages.map((page, i) => ( <ul key={i}>{page.map((t) => <li key={t.id}>{t.title}</li>)}</ul> ))} {hasNextPage && <button onClick={() => fetchNextPage()}>Load more</button>} </> ) }

The pageParam builder

pageParam is the one routar-specific concept in .infinite. Instead of writing a queryFn, you describe where the page number goes in the request — its return value (a deep-partial of the endpoint’s request) is deep-merged into the base params, then the routar client is called.

  • Declare initialPageParam, getNextPageParam, and pageParam in createQueries({ infinite: { <endpoint>: { ... } } }).
  • pageParam replaces queryFn — do not pass queryFn.
  • The field the pageParam builder writes to must exist in the endpoint’s request schema, since the merged request is validated by routar.
  • The page param type is number. For cursor-based (string) pagination, cast at the call site.
  • Other native infinite options (maxPages, getPreviousPageParam, select, staleTime, etc.) pass straight through.

Per-call override

Pass a partial override as the second argument to .infinite() — it merges over the configured contract (call wins):

todoQuery.getList.infinite( { query: { _limit: 10 } }, { staleTime: 60_000 }, // merged over config )

You can also supply the full contract at the call site for ad-hoc use, but declaring it in createQueries is the recommended pattern. If an endpoint has no infinite config and the full contract is not supplied as the override, the library throws a clear runtime error.

Key helper

.infinite.queryKey(params?) returns [...root, "getList", "infinite", params?]. Because this is a prefix-child of the standard key [...root, "getList"], invalidating the standard key — or the domain $key — also covers the infinite variant.

todoQuery.getList.infinite.queryKey({ query: { _limit: 10 } }) // → ["todos", "getList", "infinite", { query: { _limit: 10 } }] // Invalidating the standard key also hits the infinite variant: qc.invalidateQueries({ queryKey: todoQuery.getList.queryKey() })

SSR prefetch

// app/(pages)/todos/page.tsx (Next.js server component) import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { getQueryClient } from '@/utils/get-query-client' import { todoQuery } from '@/remote/services/todo' export default async function TodosPage() { const qc = getQueryClient() await qc.prefetchInfiniteQuery(todoQuery.getList.infinite({ query: { _limit: 10 } })) return ( <HydrationBoundary state={dehydrate(qc)}> <Suspense fallback={<p>Loading…</p>}> <InfiniteTodoList /> </Suspense> </HydrationBoundary> ) }

No-params endpoints

Call .infinite() without arguments (or pass undefined):

useSuspenseInfiniteQuery(todoQuery.feed.infinite())

Mutations

Every non-GET endpoint becomes a mutation accessor — a function that returns a TanStack mutationOptions object with mutationKey and mutationFn pre-filled.

// options: any useMutation option except mutationFn / mutationKey, plus invalidates todoQuery.create(options?) todoQuery.update(options?) todoQuery.remove(options?)

The variables passed to .mutate() are the routar request object:

import { useMutation } from '@tanstack/react-query' import { todoQuery } from '@/remote/services/todo' const { mutate } = useMutation(todoQuery.create()) mutate({ body: { title: 'New todo' } }) const { mutate: update } = useMutation(todoQuery.update()) update({ path: { id: '1' }, body: { title: 'Updated' } })

Mutation key helper

todoQuery.create.mutationKey // → ["todos", "create"]

Invalidation

By default mutations do not invalidate anything — you stay in full control.

Declarative invalidation with invalidates

Pass invalidates to declare which query keys to invalidate on success:

useMutation( todoQuery.create({ invalidates: [ todoQuery.getList.queryKey(), // prefer narrow: just the key(s) actually affected // todoQuery.$key // whole-domain: refetches ALL active lists + details — use only when truly needed ], }), )

Prefer narrow invalidation — target the specific key(s) affected by the mutation. Reserve todoQuery.$key for mutations that truly invalidate every query in the domain; it triggers a refetch of all active lists and details, which can be costly.

invalidates is stored in mutation.meta and processed by routarMutationCache. Wire it once when creating your QueryClient — without this wiring, invalidates does nothing. In development, the library logs a one-time console.warn if a mutation declares invalidates while no routarMutationCache is wired.

// utils/get-query-client.ts import { QueryClient } from '@tanstack/react-query' import { routarMutationCache } from '@routar/react-query' let queryClient: QueryClient queryClient = new QueryClient({ mutationCache: routarMutationCache(() => queryClient), })

Without this wiring invalidates is silently ignored — use a native onSuccess callback instead.

Manual invalidation

import { useQueryClient } from '@tanstack/react-query' const qc = useQueryClient() useMutation( todoQuery.create({ onSuccess: () => qc.invalidateQueries({ queryKey: todoQuery.getList.queryKey() }), }), )

Optimistic updates

The library does not intercept optimistic update logic — pass native TanStack handlers and they are merged in:

Adapter caveat: if the endpoint has an adapter, the value stored in the query cache is the adapted output (e.g. { ...todo, label }), not the raw response. setQueryData callbacks must produce that adapted shape — include any derived fields that the adapter adds.

const qc = useQueryClient() useMutation( todoQuery.update({ onMutate: async (vars) => { const key = todoQuery.getDetail.queryKey({ path: { id: vars.path.id } }) await qc.cancelQueries({ queryKey: key }) const prev = qc.getQueryData(key) // If the endpoint has an adapter, old already has the adapted shape — preserve derived fields qc.setQueryData(key, (old: any) => ({ ...old, ...vars.body })) return { prev, key } }, onError: (_e, _v, ctx) => qc.setQueryData(ctx!.key, ctx!.prev), onSettled: (_d, _e, vars) => qc.invalidateQueries({ queryKey: todoQuery.getDetail.queryKey({ path: { id: vars.path.id } }), }), }), )

Key structure

Query keys follow the shape [...rootSegments, endpointName, params?]:

AccessorKey (no params)Key (with params)
todoQuery.getList()["todos", "getList"]["todos", "getList", { query: { done: true } }]
todoQuery.getDetail({ path: { id: "1" } })["todos", "getDetail", { path: { id: "1" } }]

Domain key ($key)

Every accessor object exposes $key — the root segments shared by all keys in the domain:

todoQuery.$key // → ["todos"] // Invalidate everything in the domain: qc.invalidateQueries({ queryKey: todoQuery.$key })

Nested routers

Nested defineRouter calls accumulate segments:

// router prefix "/api/v1/users" userQuery.$key // → ["api", "v1", "users"] userQuery.posts.$key // → ["api", "v1", "users", "posts"] userQuery.posts.getList.queryKey() // → ["api", "v1", "users", "posts", "getList"]

Custom root key

Override the root segments at creation time:

const todoQuery = createQueries(todoApi, { key: 'todo' }) todoQuery.$key // → ["todo"]
Last updated on