Aviary
Guides

Codegen

Static analysis of your NestJS controllers that emits typed pages, routes, and a full API client surface. Zero runtime cost.

pnpm nestjs-codegen

One 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-extension
npm install --save-dev @dudousxd/nestjs-codegen @dudousxd/nestjs-inertia-codegen-extension
yarn add -D @dudousxd/nestjs-codegen @dudousxd/nestjs-inertia-codegen-extension

Configuration

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()],
});
OptionDefaultDescription
pages.glob--Glob for Inertia page components
app.moduleEntry--Path to AppModule (used to discover controllers)
contracts.globsrc/**/*.controller.tsGlob for controller files watched in watch mode
contracts.debounceMs500Debounce before re-running on file change
codegen.outDir.nestjs-inertiaDirectory 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:

FieldTypeDescription
modulestringPath to the module exporting the shared-props shape, relative to cwd
exportstringThe 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-codegen

Or 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 route

pages.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

.nestjs-inertia/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 typeWire type (Jsonify)Why
DatestringDate.prototype.toJSON() emits an ISO string
any { toJSON(): R } holder (Luxon, Moment, …)Jsonify<R>JSON.stringify serializes only the toJSON() return
bigintneverJSON.stringify throws on bigint — there is no wire value
method / function-valued propertydroppedfunctions are omitted by JSON.stringify
Map / Set{} (empty object)JSON drops their entries
any / unknown propertykept as-isno useful transform — passes straight through
optional x?: Tstays optionalan absent key is exactly JSON's "missing property"

So a controller like this:

src/orders.controller.ts
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.

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' },
  serialization: 'superjson',   // emit raw types — your fetcher revives the payload
  extensions: [nestjsInertiaCodegen()],
});
serializationDefaultGenerated responseUse when
'json'Jsonify<...> (e.g. Datestring)Plain JSON responses (the common case)
'superjson'raw controller return typeYour 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.

DecoratorExtracted as
@Body() body: CreatePostDtobody type
@Query() query: ListPostsQueryquery type
@Param('id') id: stringparams 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:

PrioritySourceBehavior
1@ApplyContract(defineContract({...}))Zod schemas win. DTO extraction is skipped for that method.
2Parameter decorators (@Body, @Query, @Param)Contributes body, query, and params types from the DTO class.
3@ApiResponse({ type: ... })Provides the response type.
4Return type annotation (Promise<PostDto[]>)Response fallback when @ApiResponse is absent.
5Nothingunknown

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

BehaviorDetail
Utility types preservedRecord<K, V>, Omit<T, K>, and Pick<T, K> are emitted with their generic arguments
Date propertiesEmitted as string via Jsonify<...> — the JSON wire shape (see Serialized response types)
bigint propertiesEmitted as never under JsonifyJSON.stringify throws on bigint, so there is no wire value
Server-only typesStreamableFile, Observable are mapped to unknown
Recursive depthClass 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 the Controller suffix stripped and first letter lowercased.
  • Method portion: method-level @As(...) if present, otherwise the method name.
  • Final name: ${classPortion}.${methodPortion}
Class @AsMethod @AsDerived name
absentabsent<classNameStripped>.<methodName>
@As('crew')absentcrew.<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 nameGenerated 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 }
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 (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'>;
HelperDescription
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:

ConditionValue
NODE_ENVnot 'production'
@dudousxd/nestjs-inertia-codegenresolvable (dynamically imported; peer-optional)
nestjs-inertia.config.tsloadable at project root
NESTJS_INERTIA_DISABLE_AUTO_CODEGENnot 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 --watch

Reruns 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 doctor

Each 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.

On this page