Skip to content

Codegen

@dudousxd/nestjs-inertia-codegen provides a CLI that statically analyses your NestJS application and emits TypeScript type files so that your frontend and any API client are fully type-safe.

Terminal window
pnpm add -D @dudousxd/nestjs-inertia-codegen

Create nestjs-inertia.config.ts at your project root:

import { defineConfig } from '@dudousxd/nestjs-inertia-codegen';
export default defineConfig({
pages: {
// Glob pattern for Inertia page components
glob: 'inertia/pages/**/*.tsx',
// Named export that holds the page's prop type
propsExport: 'ComponentProps',
},
app: {
// Path to the NestJS AppModule (used to discover controllers)
moduleEntry: './src/app.module.ts',
},
contracts: {
// Glob for controller files watched in auto/manual watch mode
glob: 'src/**/*.controller.ts',
// Debounce before re-running contract discovery on file change
debounceMs: 500,
},
// Optional: where to emit generated files (default: .nestjs-inertia/)
outDir: '.nestjs-inertia',
});
Terminal window
pnpm nestjs-inertia codegen

Or add a script to package.json:

{
"scripts": {
"codegen": "nestjs-inertia codegen"
}
}
.nestjs-inertia/
pages.d.ts — union type of all known Inertia page component names
routes.ts — typed route helpers derived from @Inertia() metadata
api.ts — typed client API surface derived from @ApplyContract() metadata
export type InertiaPages = 'Dashboard' | 'Users' | 'Settings';

Use this type to constrain the component argument wherever you construct Inertia responses programmatically.

export const routes = {
dashboard: () => '/dashboard',
users: { list: () => '/api/users' },
} as const;
export interface InertiaApi {
'users.list': {
method: 'GET';
url: '/api/users';
query: { active?: boolean };
response: Array<{ id: string; name: string }>;
};
}

This file is consumed by @dudousxd/nestjs-inertia-client’s createFetcher to produce a fully typed client.

Using class-validator DTOs (no defineContract needed)

Section titled “Using class-validator DTOs (no defineContract needed)”

For standard NestJS projects that already use class-validator DTOs and @nestjs/swagger, you get typed API generation without writing defineContract at all. The codegen reads types directly from:

  • @Body() body: CreatePostDto → body type
  • @Query() query: ListPostsQuery → query type
  • @Param('id') id: string → params type
  • @ApiResponse({ type: [PostDto] }) or @ApiResponse({ type: PostDto }) → response type
  • Return type annotation (Promise<PostDto[]>) → response fallback
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, the api.ts file will contain fully typed entries for all three methods — no defineContract, no Zod schemas required.

When building contract info for a method the codegen applies this priority:

  1. @ApplyContract(defineContract({...})) — Zod schemas win. When present, DTO extraction is skipped entirely for that method.
  2. Parameter decorators@Body(), @Query(), @Param('name') contribute body, query, and params types from the DTO class.
  3. @ApiResponse({ type: ... }) — provides the response type.
  4. Return type annotationlist(): Promise<PostDto[]> is used as a response fallback when @ApiResponse is absent.
  5. Nothingunknown.
  • @Query('key') for individual string query params is ignored (only @Query() with no argument pointing to a DTO class is extracted).
  • DTO classes must be declared in the same file as the controller for the fast AST path to resolve them. Imported DTOs fall back to the class name string.
  • Recursive class resolution is limited to depth 3 to prevent infinite loops on circular references.
  • Date properties are emitted as string (common REST API convention).
  • defineContract with Zod is the advanced path for teams that prefer explicit, validated schemas. DTOs are the zero-boilerplate path for teams already using class-validator.
import { Controller, Get } from '@nestjs/common';
import { ApplyContract, defineContract } from '@dudousxd/nestjs-inertia-client';
import { z } from 'zod';
const ListUsers = defineContract({
query: z.object({ active: z.boolean().optional() }),
response: z.array(z.object({ id: z.string(), name: z.string() })),
});
@Controller()
export class UsersController {
@Get('/api/users')
@ApplyContract(ListUsers)
list() {
return [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
];
}
}

Use standard NestJS decorators (@Get, @Post, @Put, @Patch, @Delete) as the routing source-of-truth. @ApplyContract only attaches the contract metadata — it no longer sets the HTTP method or path.

After running pnpm codegen, the auto-derived users.list contract appears in api.ts.

The API name is composed from a class portion and a method portion, joined with a dot:

  • Class portion: class-level @As(...) value if present, otherwise the class name with the Controller suffix stripped and first letter lowercased.
  • Method portion: method-level @As(...) value if present, otherwise the method name.
  • Final name: ${classPortion}.${methodPortion}
Class-level @AsMethod-level @AsDerived name
absentabsent<classNameStripped>.<methodName> (default)
@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

Use dot-notation in the final name to produce a nested path 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
// UsersController.list → api.users.list.queryOptions(...)
const { data } = useQuery(api.users.list.queryOptions({ active: true }));

@As can be applied at the class level, method level, or both. When both are present they compose: the class value becomes the class portion and the method value becomes the method portion.

import { As } from '@dudousxd/nestjs-inertia-client';
// Class-level @As sets the class portion for every method in the class
@Controller('/api/v1/crew')
@As('crew')
class CrewController {
@Get()
@ApplyContract(ListCrew)
list() { ... } // → 'crew.list'
@Get('/top')
@ApplyContract(TopCrew)
@As('top10') // → 'crew.top10'
top() { ... }
}
// Class @As can be multi-segment
@Controller('/api/v1/admin/crew')
@As('crew.admin')
class CrewAdminController {
@Get()
@ApplyContract(ListCrew)
@As('top10') // → 'crew.admin.top10'
list() { ... }
}

Each dot-separated segment must be a camelCase identifier: it must start with a lowercase letter and contain only letters and digits (no hyphens, underscores, spaces, or uppercase first letters). Codegen validates this at emit time and throws a descriptive error with a suggested fix.

  • Valid: 'users.list', 'admin.userActions.create', 'health'
  • Invalid (throws): 'user-post.list' → suggest 'userPost.list', 'user_post.list' → suggest 'userPost.list', 'User.list' → suggest 'user.list'

The generated api.ts exports two namespaces of type helpers for accessing request and response shapes without importing the full client.

import type { Route } from '.nestjs-inertia/api.js';
// Resolved type of the response field for users.list
type UserList = Route.Response<'users.list'>;
// Full request shape
type CreateUserRequest = Route.Request<'users.create'>;
// → { body: { name: string; email: string }; query: never; params: never }

All helpers: Route.Response<K>, Route.Body<K>, Route.Query<K>, Route.Params<K>, Route.Error<K>, Route.Request<K>.

import type { Path } from '.nestjs-inertia/api.js';
type ListResponse = Path.Response<'GET', '/api/users'>;
type CreateBody = Path.Body<'POST', '/api/users'>;

All helpers: Path.Response<M, U>, Path.Body<M, U>, Path.Query<M, U>, Path.Params<M, U>, Path.Error<M, U>.

When the codegen package is installed in your project, InertiaModule automatically starts the codegen watcher when your app bootstraps in dev mode. Just run your usual nest start --watch — the generated files appear under .nestjs-inertia/ and update on every controller/page save. No extra command, no extra terminal.

Auto-watch starts when all of these are true:

  • NODE_ENV !== 'production'
  • @dudousxd/nestjs-inertia-codegen is installed
  • nestjs-inertia.config.ts is present at the project root
  • The kill-switch env NESTJS_INERTIA_DISABLE_AUTO_CODEGEN is not set to '1'

You can also run pnpm nestjs-inertia codegen --watch in a separate terminal if you prefer explicit control. When both the auto-watcher AND the CLI watcher are running, only one will hold the lock and generate 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 — so a crash never leaves you stuck without codegen.

InertiaModule.forRoot({
codegen: { enabled: false },
});

Or set NESTJS_INERTIA_DISABLE_AUTO_CODEGEN=1 in your shell.

Only needed if your server bundle imports generated files (e.g. route helpers from .nestjs-inertia/routes.ts). This tells the NestJS compiler to copy and watch the output directory:

{
"compilerOptions": {
"assets": [".nestjs-inertia/**/*"],
"watchAssets": true
}
}
Terminal window
pnpm nestjs-inertia codegen --watch

Reruns whenever a controller or page file changes. When using this alongside nest start --watch, the auto-bootstrap watcher detects the held lock and becomes a no-op automatically.

Add the output directory to version control so that CI has types without running the CLI. Alternatively, add a postinstall script to generate on install and add .nestjs-inertia/ to .gitignore.