Codegen
Static analysis of your NestJS controllers that emits typed pages, routes, and a full API client surface. Zero runtime cost.
pnpm nestjs-codegenOne command. Your controllers become typed page props, a route map, and a complete API client with queryOptions, mutationOptions, and queryKey for every endpoint.
Codegen is now @dudousxd/nestjs-codegen
The typed-client codegen was extracted into the standalone @dudousxd/nestjs-codegen package. This repo ships the Inertia extension — @dudousxd/nestjs-inertia-codegen-extension — which you register so the generated api.ts gains the Inertia navigate() helper. Install both packages.
Install
pnpm add -D @dudousxd/nestjs-codegen @dudousxd/nestjs-inertia-codegen-extensionnpm install --save-dev @dudousxd/nestjs-codegen @dudousxd/nestjs-inertia-codegen-extensionyarn add -D @dudousxd/nestjs-codegen @dudousxd/nestjs-inertia-codegen-extensionConfiguration
Create nestjs-codegen.config.ts at the project root and register the Inertia extension in extensions: [...]:
// 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',
},
app: {
moduleEntry: './src/app.module.ts',
},
contracts: {
glob: 'src/**/*.controller.ts',
debounceMs: 500,
},
codegen: {
outDir: '.nestjs-inertia',
},
extensions: [nestjsInertiaCodegen()],
});| Option | Default | Description |
|---|---|---|
pages.glob | -- | Glob for Inertia page components |
app.moduleEntry | -- | Path to AppModule (used to discover controllers) |
contracts.glob | src/**/*.controller.ts | Glob for controller files watched in watch mode |
contracts.debounceMs | 500 | Debounce before re-running on file change |
codegen.outDir | .nestjs-inertia | Directory for generated files |
serialization | 'json' | Response-type wire shape: 'json' wraps responses in Jsonify<...>; 'superjson' emits raw types (see Serialized response types) |
extensions | [] | Build-time extensions, e.g. nestjsInertiaCodegen() for the Inertia navigate() helper |
The same extensions array works when registering the codegen on the Nest module via NestjsCodegenModule.forRoot({ extensions: [nestjsInertiaCodegen()] }) (@dudousxd/nestjs-codegen/nest). Keep one source of truth and import it into both.
Extension options
nestjsInertiaCodegen(options?) always contributes the navigate() helper to api.ts. Two opt-in, additive options make it emit extra files — called with no arguments, it emits nothing beyond that helper:
extensions: [
nestjsInertiaCodegen({
// Emit shared.ts — the `InertiaSharedProps` type
shared: { module: './src/shared-props.ts', export: 'getSharedProps', kind: 'function' },
// Emit page-excludes.ts — every @Inertia page route (path + method)
pageExcludes: true,
}),
],shared — emit shared.ts
Emits an InertiaSharedProps type derived from your shared-props source, so the client can type usePage().props against it. SharedPropsSource is:
| Field | Type | Description |
|---|---|---|
module | string | Path to the module exporting the shared-props shape, relative to cwd |
export | string | The named export to read |
kind | 'function' | 'type' | 'function': the export is a factory (e.g. the share() callback) and its return type is used. 'type': the export is the shape (a type alias or interface). |
// kind: 'function' — reads ReturnType of the share factory
shared: { module: './src/shared-props.ts', export: 'getSharedProps', kind: 'function' }
// kind: 'type' — the export is already the shape
shared: { module: './src/types.ts', export: 'SharedProps', kind: 'type' }pageExcludes: true — emit page-excludes.ts
Emits every @Inertia page route (its path and HTTP method) as an array ready to feed setGlobalPrefix's exclude, so your API prefix skips the Inertia page routes:
import { pageExcludes } from './.nestjs-inertia/page-excludes.js';
app.setGlobalPrefix('api', { exclude: pageExcludes });pageExcludes: true throws if code generation discovers zero @Inertia pages — a fail-fast guard against silently emitting an empty exclude list (which would route your pages under /api). It resolves each decorator to its import origin via AST inspection, so re-exports and import * as X namespaces are handled correctly.
Running
pnpm nestjs-codegenOr add a script to package.json:
{
"scripts": {
"codegen": "nestjs-codegen"
}
}Output structure
.nestjs-inertia/
pages.d.ts — module augmentation for typed @Inertia() and page props
routes.ts — typed route map + route() helper for URL resolution
api.ts — queryOptions(), mutationOptions(), queryKey() for every routepages.d.ts
Props are inferred directly from the default export of each page component using Parameters<typeof import('...').default>[0]. No ComponentProps export needed.
export type InertiaPageName = 'Dashboard' | 'Users' | 'Settings';
declare module '@dudousxd/nestjs-inertia' {
interface InertiaPages {
Dashboard: Parameters<typeof import('../inertia/pages/Dashboard').default>[0];
Users: Parameters<typeof import('../inertia/pages/Users').default>[0];
Settings: Parameters<typeof import('../inertia/pages/Settings').default>[0];
}
}This augments the @Inertia() decorator to only accept valid page names. Write @Inertia('Dashbord') and TypeScript errors immediately.
routes.ts
export const ROUTES = {
"users.list": "/api/users",
"users.show": "/api/users/:id",
} as const;
export type RouteName = keyof typeof ROUTES;
export function route(name: RouteName, params?: Record<string, string>): string;The route() helper interpolates params into the URL pattern:
route('users.show', { id: '42' })
// → "/api/users/42"api.ts
import { queryOptions as _queryOptions, mutationOptions as _mutationOptions } from '@tanstack/react-query';
import type { Jsonify } from '@dudousxd/nestjs-inertia-client';
export interface ApiRouter {
users: {
list: {
method: "GET";
url: "/api/users";
query: { active?: boolean };
body: never;
response: Jsonify<Awaited<ReturnType<import('../src/users.controller').UsersController['list']>>>;
};
show: {
method: "GET";
url: "/api/users/:id";
params: { id: string };
query: never;
body: never;
response: Jsonify<Awaited<ReturnType<import('../src/users.controller').UsersController['show']>>>;
};
};
}
export const api = {
users: {
list: {
queryOptions: (query?) => _queryOptions({ queryKey: ["users.list", query], queryFn: () => fetcher.get("/api/users", { query }) }),
queryKey: (query?) => ["users.list", query] as const,
},
show: {
queryOptions: (params, query?) => _queryOptions({ queryKey: ["users.show", params, query], queryFn: () => fetcher.get(`/api/users/${params.id}`, { query }) }),
queryKey: (params, query?) => ["users.show", params, query] as const,
},
},
};Response types stay in sync with your controller automatically — no annotations needed. The raw Awaited<ReturnType<import(...)>> is wrapped in Jsonify<...> so the generated type matches what actually arrives on the client after JSON serialization (e.g. a controller returning { createdAt: Date } produces { createdAt: string }). See Serialized response types below.
Serialized response types
By default the codegen wraps every emitted response type in Jsonify<...>, a type-only helper exported from @dudousxd/nestjs-inertia-client. Jsonify<T> models the value you actually receive on the client — the result of JSON.parse(JSON.stringify(value)) — rather than the in-process server type.
This matters because the JSON wire shape differs from your controller's return type:
| Server type | Wire type (Jsonify) | Why |
|---|---|---|
Date | string | Date.prototype.toJSON() emits an ISO string |
any { toJSON(): R } holder (Luxon, Moment, …) | Jsonify<R> | JSON.stringify serializes only the toJSON() return |
bigint | never | JSON.stringify throws on bigint — there is no wire value |
| method / function-valued property | dropped | functions are omitted by JSON.stringify |
Map / Set | {} (empty object) | JSON drops their entries |
any / unknown property | kept as-is | no useful transform — passes straight through |
optional x?: T | stays optional | an absent key is exactly JSON's "missing property" |
So a controller like this:
import { Controller, Get, Param } from '@nestjs/common';
class OrderDto {
id: string;
total: number;
placedAt: Date; // ← Date on the server
}
@Controller('/api/orders')
export class OrdersController {
@Get(':id')
show(@Param('id') id: string): Promise<OrderDto> {
return this.ordersService.findOne(id);
}
}generates a response of Jsonify<OrderDto>, i.e. { id: string; total: number; placedAt: string }. On the client data.placedAt is correctly typed as string, so new Date(data.placedAt) is the right way to revive it.
Jsonify is a hand-rolled, type-only utility with no runtime footprint and no external dependency. It only changes types — the codegen does not inject any deserialization code. If you need real Date/Map/Set instances back on the client, revive the payload yourself (or use the superjson opt-out below).
Opting out with superjson
If your fetcher revives payloads with superjson (so Date, Map, and Set are restored at runtime), set serialization: 'superjson' in the codegen config. The codegen then emits the raw controller return type unchanged — no Jsonify<...> wrapping — because the values are reconstructed before you read them.
import { defineConfig } from '@dudousxd/nestjs-codegen';
import { nestjsInertiaCodegen } from '@dudousxd/nestjs-inertia-codegen-extension';
export default defineConfig({
pages: { glob: 'inertia/pages/**/*.tsx' },
app: { moduleEntry: './src/app.module.ts' },
serialization: 'superjson', // emit raw types — your fetcher revives the payload
extensions: [nestjsInertiaCodegen()],
});serialization | Default | Generated response | Use when |
|---|---|---|---|
'json' | ✓ | Jsonify<...> (e.g. Date → string) | Plain JSON responses (the common case) |
'superjson' | raw controller return type | Your client revives payloads with superjson |
serialization: 'superjson' only affects the generated types. It does not add a runtime superjson hook to the fetcher — you are responsible for wiring superjson deserialization in your own fetcher (see Fetcher setup). Pick 'superjson' only if your payloads are genuinely revived; otherwise the types will claim Date where the runtime value is still a string.
Using class-validator DTOs
The codegen reads types directly from your existing NestJS decorators. No defineContract, no Zod schemas.
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiResponse } from '@nestjs/swagger';
class ListPostsQuery {
page?: number;
}
class PostDto {
id: string;
title: string;
}
class CreatePostBody {
title: string;
content: string;
}
@Controller('/api/posts')
export class PostsController {
@Get()
@ApiResponse({ type: [PostDto] })
list(@Query() query: ListPostsQuery): Promise<PostDto[]> {
return this.postsService.list(query);
}
@Post()
@ApiResponse({ type: PostDto })
create(@Body() body: CreatePostBody): Promise<PostDto> {
return this.postsService.create(body);
}
@Get(':id')
@ApiResponse({ type: PostDto })
show(@Param('id') id: string): Promise<PostDto> {
return this.postsService.findOne(id);
}
}After running pnpm codegen, api.ts contains fully typed entries for all three methods.
| Decorator | Extracted as |
|---|---|
@Body() body: CreatePostDto | body type |
@Query() query: ListPostsQuery | query type |
@Param('id') id: string | params type |
@ApiResponse({ type: PostDto }) | response type |
Return type Promise<PostDto[]> | response fallback |
@Query('key') for individual string query params is ignored. Only @Query() with no argument pointing to a DTO class is extracted.
Priority chain
When the codegen builds contract info for a method, it applies this priority:
| Priority | Source | Behavior |
|---|---|---|
| 1 | @ApplyContract(defineContract({...})) | Zod schemas win. DTO extraction is skipped for that method. |
| 2 | Parameter decorators (@Body, @Query, @Param) | Contributes body, query, and params types from the DTO class. |
| 3 | @ApiResponse({ type: ... }) | Provides the response type. |
| 4 | Return type annotation (Promise<PostDto[]>) | Response fallback when @ApiResponse is absent. |
| 5 | Nothing | unknown |
Cross-file DTO resolution
The codegen follows import declarations and resolves tsconfig.json path aliases (e.g. @/*) automatically. DTO classes, interfaces, type aliases, and enums are all resolved across files.
// src/dto/post.dto.ts
export class PostDto {
id: string;
title: string;
}
// src/posts.controller.ts
import { PostDto } from './dto/post.dto';
@Controller('/api/posts')
export class PostsController {
@Get()
list(): Promise<PostDto[]> {
return this.postsService.list();
}
}The codegen resolves PostDto from ./dto/post.dto and emits the correct type in api.ts.
Notes and limitations
| Behavior | Detail |
|---|---|
| Utility types preserved | Record<K, V>, Omit<T, K>, and Pick<T, K> are emitted with their generic arguments |
Date properties | Emitted as string via Jsonify<...> — the JSON wire shape (see Serialized response types) |
bigint properties | Emitted as never under Jsonify — JSON.stringify throws on bigint, so there is no wire value |
| Server-only types | StreamableFile, Observable are mapped to unknown |
| Recursive depth | Class resolution is limited to depth 3 to prevent infinite loops on circular references |
@Query('key') | Individual string query params are ignored (only DTO-style @Query() is extracted) |
How the API name is chosen
The API name is composed from a class portion and a method portion, joined with a dot.
- Class portion: class-level
@As(...)if present, otherwise the class name with theControllersuffix stripped and first letter lowercased. - Method portion: method-level
@As(...)if present, otherwise the method name. - Final name:
${classPortion}.${methodPortion}
Class @As | Method @As | Derived name |
|---|---|---|
| absent | absent | <classNameStripped>.<methodName> |
@As('crew') | absent | crew.<methodName> |
| absent | @As('top10') | <classNameStripped>.top10 |
@As('crew') | @As('directory.fetch') | crew.directory.fetch |
@As('crew.admin') | @As('top10') | crew.admin.top10 |
Dot-notation in the final name produces nested paths under api.*.*:
| Final name | Generated accessor |
|---|---|
'users.list' | api.users.list |
'users.show' | api.users.show |
'adminUsers.create' | api.adminUsers.create |
'crew.admin.top10' | api.crew.admin.top10 |
import { As } from '@dudousxd/nestjs-inertia-client';
@Controller('/api/v1/crew')
@As('crew')
class CrewController {
@Get()
list() {} // → 'crew.list'
@Get('/top')
@As('top10') // → 'crew.top10'
top() {}
}
@Controller('/api/v1/admin/crew')
@As('crew.admin')
class CrewAdminController {
@Get()
@As('top10') // → 'crew.admin.top10'
list() {}
}Each dot-separated segment must be a camelCase identifier: starts with a lowercase letter, contains only letters and digits. Codegen validates at emit time and throws a descriptive error with a suggested fix.
Route.* and Path.* type helpers
The generated api.ts exports two namespaces for accessing request and response shapes without importing the full client.
Route.* -- by contract name
import type { Route } from '~codegen/api';
type UserList = Route.Response<'users.list'>;
type CreateUserRequest = Route.Request<'users.create'>;
// → { body: { name: string; email: string }; query: never; params: 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 (nestjs-filter), or never |
Path.* -- by HTTP method + URL pattern
import type { Path } from '~codegen/api';
type ListResponse = Path.Response<'GET', '/api/users'>;
type CreateBody = Path.Body<'POST', '/api/users'>;| Helper | Description |
|---|---|
Path.Response<M, U> | Response type for method M at URL U |
Path.Body<M, U> | Body type |
Path.Query<M, U> | Query type |
Path.Params<M, U> | Params type |
Path.Error<M, U> | Error type |
Path.FilterFields<M, U> | Filterable-field union, or never |
Auto-watch
InertiaModule can start a codegen file watcher automatically on bootstrap in dev mode, so nest start --watch alone regenerates files on every controller or page save — no extra command, no extra terminal.
Auto-watch starts when all of these are true:
| Condition | Value |
|---|---|
NODE_ENV | not 'production' |
@dudousxd/nestjs-inertia-codegen | resolvable (dynamically imported; peer-optional) |
nestjs-inertia.config.ts | loadable at project root |
NESTJS_INERTIA_DISABLE_AUTO_CODEGEN | not set to '1' |
Disable auto-watch in the module:
InertiaModule.forRoot({
codegen: { enabled: false },
});Or set NESTJS_INERTIA_DISABLE_AUTO_CODEGEN=1 in your shell.
This auto-watch integration predates the @dudousxd/nestjs-codegen extraction and still targets the legacy @dudousxd/nestjs-inertia-codegen package name, not @dudousxd/nestjs-codegen. If you only installed @dudousxd/nestjs-codegen (the current, supported codegen — see Install above), the auto-watch import will fail to resolve and log a warning; it does not block boot. Run the CLI watcher explicitly instead — see CLI watch mode below — or set codegen: { enabled: false } to silence the warning.
If your server bundle imports generated files (e.g. route helpers from .nestjs-inertia/routes.ts), add this to nest-cli.json:
{
"compilerOptions": {
"assets": [".nestjs-inertia/**/*"],
"watchAssets": true
}
}CLI watch mode
pnpm nestjs-codegen --watchReruns whenever a controller or page file changes. When both the auto-watcher and the CLI watcher are running, only one holds the lock and generates files. The other becomes a no-op and logs a warning. Stale locks from crashed processes are detected via PID-liveness check and overwritten automatically.
Doctor command
The nestjs-inertia doctor command (from @dudousxd/nestjs-inertia-cli) diagnoses the known setup pitfalls: InertiaModule registration, the legacy auto-watch left enabled (codegen: { enabled: false } missing), CLI/module codegen config drift, root view shell + Vite entry + nestInertia plugin, the Vite client manifest after a build, @dudousxd/nestjs-inertia >= 1.4.4 (streaming fix), the flat registry .d.ts shape, peer version coherence, and .gitignore.
pnpm exec nestjs-inertia doctorEach check prints pass/warn/fail with a one-line fix pointer to the relevant docs page; the command exits 1 when any check fails.
Generated file management
Add .nestjs-inertia/ to .gitignore and run nestjs-codegen in CI before TypeScript compilation. Alternatively, commit the output directory so CI has types without running the CLI.