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 type | Generated | Used with |
|---|---|---|
| GET | queryOptions() | useQuery |
| POST / PUT / PATCH / DELETE | mutationOptions() | useMutation |
| All | queryKey() | 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 fetcherTo 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`)| Helper | Description |
|---|---|
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.
navigate()
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>
);
}| Option | Type | Description |
|---|---|---|
only | Array<keyof PageProps> | Only reload these props |
except | Array<keyof PageProps> | Reload all props except these |
preserveScroll | boolean | Keep scroll position |
preserveState | boolean | Keep 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.