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.

On this page