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.


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

When @dudousxd/nestjs-codegen is installed and a nestjs-codegen.config.ts is present, InertiaModule starts the codegen watcher automatically on bootstrap in dev mode. Run your usual nest start --watch and the generated files appear under .nestjs-inertia/ 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-codegeninstalled
nestjs-codegen.config.tspresent 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.

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) checks tsconfig paths, codegen output, package versions, Inertia v3, .gitignore, and Vite config.

pnpm exec nestjs-inertia doctor

To auto-fix issues:

pnpm exec nestjs-inertia doctor --fix

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