Aviary
Client

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-tanstack
src/app.module.ts
import { 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 awaitableawait 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.

On this page