TanStack Query
queryOptions / mutationOptions from your framework adapter.
TanStack Query is an extension, not a core flag. By default the client is a plain typed
fetch with no TanStack dependency. Install the extension
and register it, and each endpoint returns a handle exposing TanStack's
queryOptions/mutationOptions helpers — GET routes get queryOptions(), everything
else mutationOptions().
pnpm add -D @dudousxd/nestjs-codegen-tanstackimport { tanstackQuery } from '@dudousxd/nestjs-codegen-tanstack';
NestjsCodegenModule.forRoot({
contracts: { glob: 'src/**/*.controller.ts' },
codegen: { outDir: 'src/generated' },
extensions: [tanstackQuery()],
});Which package?
You don't install @tanstack/query-core directly (nobody does) — your framework
adapter re-exports the helpers. Point the extension's import at the package you already
have (@tanstack/react-query is the default):
tanstackQuery(); // import defaults to '@tanstack/react-query'tanstackQuery({ import: '@tanstack/vue-query' });tanstackQuery({ import: '@tanstack/svelte-query' });tanstackQuery({ import: '@tanstack/solid-query' });Usage
The leaf is still awaitable — await api.users.list()
does a plain request — and the same handle exposes the TanStack helpers:
import { useQuery, useMutation, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
function Users() {
const qc = useQueryClient();
const list = useQuery(api.users.list().queryOptions());
const pages = useInfiniteQuery(api.users.list().infiniteQueryOptions());
const create = useMutation({
...api.users.create().mutationOptions(),
onSuccess: () => qc.invalidateQueries({ queryKey: api.users.list().queryKey() }),
});
// …
}infiniteQueryOptions() (GET routes) adds page to the query and reads the next page from
response.meta.page / response.meta.lastPage.
Each handle builds a real queryOptions/mutationOptions object, so you compose it with
your own onSuccess, select, staleTime, etc. — and queryKey() derives a stable key
from the route name + input for you.
@AsQuery() — treating a non-GET route as a read
GET routes (and filter-search routes) get queryOptions(); every
other method gets mutationOptions(). If a route's semantics are actually a read — e.g. a
POST that accepts a query-shaped payload too large or complex for a query string — mark it
with the @AsQuery() marker and it's treated like a GET for TanStack purposes too:
import { AsQuery } from '@dudousxd/nestjs-codegen/markers';
@Controller('reports')
class ReportsController {
@Post('search')
@AsQuery()
search(@Body() body: SearchDto) { /* … */ }
}const results = useQuery(api.reports.search({ body }).queryOptions());@AsQuery() is a runtime no-op — codegen detects it statically via an AST scan, not
reflect-metadata — so importing it from the zero-dependency /markers subpath has no
runtime cost and no effect outside the generated client. See Programmatic API.
Picking between handles dynamically (handleQuery)
Spreading a ternary of two different .queryOptions() calls into useQuery breaks the
overload — the two branches' generic instantiations don't unify. handleQuery, exported from
the generated api.ts whenever the TanStack extension is registered, widens any
{ queryKey, fetch }-shaped handle (every leaf has both) into the plain
{ queryKey, queryFn } pair useQuery accepts directly:
import { useQuery } from '@tanstack/react-query';
import { api, handleQuery } from '../lib/api';
const handle = isDraft ? api.reports.draft({ params }) : api.reports.published({ params });
const results = useQuery(handleQuery(handle));handleQuery is a helper for you to call at the useQuery call site — nothing generated
calls it for you, and it's emitted regardless of how many routes you have as long as the
TanStack extension is active.