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.
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?):
| Argument | Type | Description |
|---|---|---|
interval | number | Poll interval in ms |
options.only | Array<keyof PageProps> | Only reload these props |
options.except | Array<keyof PageProps> | Reload all props except these |
pollOptions.keepAlive | boolean | Keep polling while the tab is in the background |
pollOptions.autoStart | boolean | Start 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:
| Option | Type | Description |
|---|---|---|
routeParams | RegistryRoutes[K] | Route params (present iff the route declares them) |
query | Record<string, unknown> | Query-string params |
cacheFor | number | 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> prop | Type | Description |
|---|---|---|
data | prop key(s) | Prop name(s) to load when visible (keyed by the page) |
buffer | number | Pixels of pre-load margin before the element enters view |
always | boolean | Re-fetch every time it re-enters view, not just once |
as | string | The wrapper element tag |
params | reload options | Forwarded to the underlying partial-reload request |