Aviary
Guides

Typed Client

Auto-generated type-safe API client with queryOptions, mutationOptions, and queryKey for every controller endpoint. Zero configuration.

import { useQuery } from '@tanstack/react-query';
import { api } from '~codegen/api';

const { data: users } = useQuery(api.users.list.queryOptions());

The codegen reads your NestJS controllers and generates a fully typed API client in .nestjs-inertia/api.ts. No contracts, no schemas, no manual type definitions.

Every controller method gets:

Route typeGeneratedUsed with
GETqueryOptions()useQuery
POST / PUT / PATCH / DELETEmutationOptions()useMutation
AllqueryKey()invalidateQueries / setQueryData

Response types are inferred directly from your controller method via Awaited<ReturnType<import('...').Controller['method']>> — no explicit return type annotation needed. By default that type is wrapped in Jsonify<...> so it reflects the JSON wire shape your client actually receives (e.g. a Date becomes string). Opt out with serialization: 'superjson' if your fetcher revives payloads. See Serialized response types.


Queries

import { useQuery } from '@tanstack/react-query';
import { api } from '~codegen/api';

// Simple query
const { data: crew } = useQuery(api.crew.getCrew.queryOptions());

// Query with query string — { page: 2 } becomes ?page=2
const { data: posts } = useQuery(api.posts.list.queryOptions({ page: 2 }));

// Typed from @Query() DTO in the controller
const { data: filtered } = useQuery(
  api.posts.list.queryOptions({ page: 1, status: 'published' })
);

// Conditional query (spread pattern)
const { data } = useQuery({
  ...api.users.show.queryOptions({ id: userId }),
  enabled: !!userId,
});

Mutations

import { useMutation } from '@tanstack/react-query';
import { api } from '~codegen/api';

const createPost = useMutation(api.posts.create.mutationOptions());

// Body is fully typed from the controller's @Body() parameter
await createPost.mutateAsync({ title: 'Hello', content: 'World' });

Fetcher setup

The codegen imports fetcher from ~/lib/api (your file, not generated). You create the fetcher instance with your own baseUrl, headers, and plugins — like Tuyau's createTuyau.

Create inertia/lib/api.ts:

import { createFetcher, setGlobalHeaders } from '@dudousxd/nestjs-inertia-client';
import { getToken } from './auth/getToken';

// Auth headers injected on every request
setGlobalHeaders(() => {
  const token = getToken();
  return token ? { Authorization: `Bearer ${token}` } : {};
});

export const fetcher = createFetcher({
  baseUrl: '/api',  // prefix for all API routes
});

The codegen then generates:

// .nestjs-inertia/api.ts (generated — do not edit)
import { fetcher } from '~/lib/api';  // YOUR fetcher
// ... queryOptions, mutationOptions using your fetcher

To customize the import path, set fetcher.importPath in the codegen config:

// nestjs-codegen.config.ts
import { defineConfig } from '@dudousxd/nestjs-codegen';
import { nestjsInertiaCodegen } from '@dudousxd/nestjs-inertia-codegen-extension';

export default defineConfig({
  pages: { glob: 'inertia/pages/**/*.tsx' },
  fetcher: { importPath: '@/my-custom-api' },
  extensions: [nestjsInertiaCodegen()],
});

Works with any auth provider — Keycloak, Auth0, Firebase, API keys, CSRF tokens, or custom headers.


Cache invalidation

import { useQueryClient } from '@tanstack/react-query';
import { api } from '~codegen/api';

const queryClient = useQueryClient();

// Typed key — no magic strings
queryClient.invalidateQueries({ queryKey: api.crew.getCrew.queryKey() });

// With query params
queryClient.invalidateQueries({ queryKey: api.posts.list.queryKey({ page: 2 }) });

URL params

Routes with dynamic segments (e.g. /users/:id) accept a params argument:

// GET with params
const { data } = useQuery(api.users.show.queryOptions({ id: userId }));

// Mutation with params + body
const update = useMutation(api.crew.updateCrew.mutationOptions());
await update.mutateAsync({ params: { id: crewId }, body: { name: 'New Name' } });

Infinite queries

For paginated endpoints that return { data, meta: { page, lastPage } }, use infiniteQueryOptions():

import { useInfiniteQuery } from '@tanstack/react-query';
import { api } from '~codegen/api';

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(
  api.posts.list.infiniteQueryOptions()
);

The codegen reads meta.page and meta.lastPage from the response to derive getNextPageParam automatically.


Route helper

For manual fetcher calls, use route() from the generated routes:

import { fetcher } from '~/lib/api';
import { route } from '~codegen/routes';

const result = await fetcher.patch(route('crew.updateCrew', { id: userId }), {
  body: { name: 'New Name' },
});

Route.* type helpers

Extract request and response types from any route without importing the full client:

import type { Route } from '~codegen/api';

type CrewData = Route.Response<'crew.getCrew'>;
type CreateBody = Route.Body<'posts.create'>;
type ListQuery = Route.Query<'posts.list'>;
type ShowParams = Route.Params<'users.show'>;
type FullRequest = Route.Request<'users.create'>;
// → { body: { name: string; email: string }; query: never; params: never }
type RunFields = Route.FilterFields<'pipelineRuns.search'>;
// → 'status' | 'tasks.name'  (the route's filterable fields, or `never`)
HelperDescription
Route.Response<K>Response type for route K
Route.Body<K>Body type for route K
Route.Query<K>Query type for route K
Route.Params<K>Params type for route K
Route.Error<K>Error type for route K
Route.Request<K>Full request shape ({ body, query, params })
Route.FilterFields<K>Filterable-field union for route K (nestjs-filter), or never if the route has no filter

Route.FilterFields<K> is a pure type-level helper — it resolves to a string-literal union baked into the generated ApiRouter. It does not require @dudousxd/nestjs-filter (or its client) to be installed; routes without a filter simply resolve to never.


Type-safe wrapper around Inertia's router.visit() with route name autocomplete:

import { navigate } from '~codegen/api';

// Route name autocompleted, params required when route has :id
navigate('users.show', { params: { id: '42' } });
navigate('showDashboard.show');

// With Inertia visit options
navigate('users.list', { preserveState: true, replace: true });

navigate() is added to api.ts by the nestjsInertiaCodegen() extension. It imports Inertia's router from @inertiajs/react. Register the extension in extensions: [...]; without it, api.ts is a plain typed-fetch client with no navigate().


Prefetch on hover

Pass prefetch and queryClient to <Link> to prefetch query data on mouse enter:

import { useQueryClient } from '@tanstack/react-query';
import { Link } from '@dudousxd/nestjs-inertia-client/react';
import { api } from '~codegen/api';

const qc = useQueryClient();

<Link
  route="users.list"
  prefetch={api.users.list.queryOptions()}
  queryClient={qc}
>
  All Users
</Link>

Prefetches once on first hover. No duplicate requests.


useTypedReload()

Typed partial reload with prop name autocomplete:

import { useTypedReload } from '@dudousxd/nestjs-inertia-client/react';

function Dashboard() {
  const reload = useTypedReload<'Dashboard'>();

  return (
    <button onClick={() => reload({ only: ['users'] })}>
      Refresh users
    </button>
  );
}
OptionTypeDescription
onlyArray<keyof PageProps>Only reload these props
exceptArray<keyof PageProps>Reload all props except these
preserveScrollbooleanKeep scroll position
preserveStatebooleanKeep component state

Typed shared props

The codegen reads InertiaModule.forRoot({ share: ... }) and generates InertiaSharedProps automatically. Two approaches:

Named function (recommended): export the share function and the codegen uses ReturnType<import(...)>:

// src/shared-props.ts
export async function getSharedProps(req: Request) {
  return {
    auth: req.user ? { id: req.user.id, name: req.user.name } : null,
  };
}

// src/app.module.ts
InertiaModule.forRoot({ share: getSharedProps })

Generated type stays in sync automatically (same as controller response types).

Inline arrow: the codegen extracts properties from the return value:

InertiaModule.forRoot({
  share: (req) => ({
    auth: req.user ?? null,
    flash: {},
  }),
})

usePage().props now includes shared props with full type safety.


useTypedPoll()

Poll the current page on an interval, keyed by prop names. Unlike the TanStack-Query prefetch used by <Link prefetch>, this routes through Inertia v2's native usePoll (router.poll), so polled responses flow back through the normal page-prop merge pipeline (including merge / matchOn). It returns the native { start, stop } controls.

import { useTypedPoll } from '@dudousxd/nestjs-inertia-client/react';

function Inbox() {
  // re-fetch only `messages` every 5s
  const { start, stop } = useTypedPoll<'Inbox'>(5000, { only: ['messages'] });
  // …
}

useTypedPoll(interval, options?, pollOptions?):

ArgumentTypeDescription
intervalnumberPoll interval in ms
options.onlyArray<keyof PageProps>Only reload these props
options.exceptArray<keyof PageProps>Reload all props except these
pollOptions.keepAlivebooleanKeep polling while the tab is in the background
pollOptions.autoStartbooleanStart immediately (default) or wait for start()
pollOptions.mode'overlap' | 'cancel' | 'rest'How overlapping requests are handled

prefetchRoute()

Imperatively prefetch a typed route via Inertia v2's native router.prefetch (cached in Inertia's own prefetch cache, not TanStack Query). Pass the codegen route resolver — the same one given to <InertiaRouteProvider> — so the href resolves type-safely:

import { prefetchRoute } from '@dudousxd/nestjs-inertia-client/react';
import { route } from './.nestjs-inertia/routes.js';

prefetchRoute(route, 'users.show', { routeParams: { id }, cacheFor: '30s' });

prefetchRoute(resolve, route, options?)routeParams is required only when the route has params (enforced by the type), and:

OptionTypeDescription
routeParamsRegistryRoutes[K]Route params (present iff the route declares them)
queryRecord<string, unknown>Query-string params
cacheFornumber | string | [stale, cache]How long the response stays fresh/cached — ms, '30s', '1m', or a [staleTime, cacheTime] tuple

<Deferred> and <WhenVisible>

Companions to the server's Inertia.defer() marker. Both are thin typed wrappers over the official @inertiajs/* components — they mirror them verbatim at runtime, adding only that data is keyed by the page's prop names when you supply the page key as the generic.

<Deferred> renders a fallback until the deferred prop(s) arrive on the follow-up partial reload:

import { Deferred } from '@dudousxd/nestjs-inertia-client/react';

<Deferred<'Dashboard'> data="stats" fallback={<Spinner />}>
  {(page) => <Stats value={page /* typed */} />}
</Deferred>
<script setup lang="ts">
import { Deferred } from '@dudousxd/nestjs-inertia-client/vue';
</script>

<template>
  <Deferred data="stats">
    <template #fallback><Spinner /></template>
    <Stats />
  </Deferred>
</template>
<script lang="ts">
  import { Deferred } from '@dudousxd/nestjs-inertia-client/svelte';
</script>

<Deferred data="stats">
  <svelte:fragment slot="fallback"><Spinner /></svelte:fragment>
  <Stats />
</Deferred>

<WhenVisible> loads its data prop(s) only when the element scrolls into view — ideal for below-the-fold panels and infinite lists:

import { WhenVisible } from '@dudousxd/nestjs-inertia-client/react';

<WhenVisible<'Report'> data="chart" fallback={<Skeleton />} buffer={200}>
  {({ fetching }) => (fetching ? <Skeleton /> : <Chart />)}
</WhenVisible>
<WhenVisible> propTypeDescription
dataprop key(s)Prop name(s) to load when visible (keyed by the page)
buffernumberPixels of pre-load margin before the element enters view
alwaysbooleanRe-fetch every time it re-enters view, not just once
asstringThe wrapper element tag
paramsreload optionsForwarded to the underlying partial-reload request

On this page