Aviary

Getting Started

Add Inertia.js to an existing NestJS project in minutes -- install the packages, run nestjs-inertia init, and you're done.

The fastest way to get Inertia.js running in NestJS is two steps: install the packages, then run nestjs-inertia init (from @dudousxd/nestjs-inertia-cli). The init command scaffolds the config files and prints the exact app.module.ts and main.ts wiring to paste. Most projects are done in under three minutes.


Prerequisites

  • Node.js 20+
  • NestJS 10+ (v10, v11 and v12 are supported)
  • TypeScript 5+
  • One of: React, Vue 3, or Svelte 5

Step 1 -- Install

Install the five packages that make up the suite:

pnpm add @dudousxd/nestjs-inertia @dudousxd/nestjs-inertia-vite @dudousxd/nestjs-inertia-client
pnpm add -D @dudousxd/nestjs-inertia-cli @dudousxd/nestjs-codegen @dudousxd/nestjs-inertia-codegen-extension @dudousxd/nestjs-inertia-testing
npm install @dudousxd/nestjs-inertia @dudousxd/nestjs-inertia-vite @dudousxd/nestjs-inertia-client
npm install --save-dev @dudousxd/nestjs-inertia-cli @dudousxd/nestjs-codegen @dudousxd/nestjs-inertia-codegen-extension @dudousxd/nestjs-inertia-testing
yarn add @dudousxd/nestjs-inertia @dudousxd/nestjs-inertia-vite @dudousxd/nestjs-inertia-client
yarn add -D @dudousxd/nestjs-inertia-cli @dudousxd/nestjs-codegen @dudousxd/nestjs-inertia-codegen-extension @dudousxd/nestjs-inertia-testing

Codegen is now @dudousxd/nestjs-codegen

The typed-client codegen lives in the standalone @dudousxd/nestjs-codegen package; this suite ships the Inertia extension @dudousxd/nestjs-inertia-codegen-extension. Register nestjsInertiaCodegen() in extensions: [...] to add the Inertia navigate() helper to the generated api.ts.

Then install your framework's Inertia adapter and Vite plugin:

pnpm add @inertiajs/react@^3.0.0 react react-dom
pnpm add -D @types/react @types/react-dom @vitejs/plugin-react
pnpm add @inertiajs/vue3@^3.0.0 vue
pnpm add -D @vitejs/plugin-vue
pnpm add @inertiajs/svelte@^3.0.0 svelte
pnpm add -D @sveltejs/vite-plugin-svelte

Step 2 -- Run nestjs-inertia init

pnpm exec nestjs-inertia init
npx nestjs-inertia init
yarn nestjs-inertia init

This command detects your frontend framework from package.json (pass --framework react|vue|svelte to override) and scaffolds the config files in one shot.

Files created by init

FileWhat it is
nestjs-codegen.config.tsShared codegen config (with nestjsInertiaCodegen() registered) exported as ONE object -- import it into NestjsCodegenModule.forRoot too, so the CLI and module entrypoints never drift
nestjs-inertia.d.tsStub for handwritten registry augmentations (codegen writes the generated ones into .nestjs-inertia/pages.d.ts)
inertia/index.htmlHTML shell with the @inertia / @inertiaHead / @vite directives
inertia/app.tsx (or .ts)Frontend entry point bootstrapping createInertiaApp
vite.config.tsVite config with the framework plugin + nestInertia plugin + @/~/~codegen aliases
.gitignoreCreated or appended with .nestjs-inertia/

init then prints the manual wiring as next steps: the InertiaModule.forRoot(...) registration snippet (with codegen: { enabled: false }), the setupInertiaVite bootstrap for main.ts, and the codegen / build:client scripts to add to package.json -- see Step 3 below.

Every create is idempotent: re-running init skips files that already exist (pass --force to overwrite), and --dry-run previews what would be written without touching anything.

If vite.config.ts already exists it is left untouched. Run nestjs-inertia doctor afterwards to verify it references the nestInertia plugin.


Step 3 -- Wire the module and Vite

init prints these exact snippets; paste them into your app.

src/app.module.ts -- register InertiaModule (note codegen: { enabled: false } -- the module's legacy auto-watch targets the old codegen package; the nestjs-codegen CLI replaces it, see the Codegen guide):

import { resolve } from 'node:path';
import { InertiaModule } from '@dudousxd/nestjs-inertia';

@Module({
  imports: [
    InertiaModule.forRoot({
      rootView: resolve(__dirname, '../inertia/index.html'),
      codegen: { enabled: false },
    }),
  ],
})
export class AppModule {}

Why resolve(__dirname)?

The compiled code runs from dist/src/, not the project root. Using resolve(__dirname, '..') ensures the path resolves correctly both locally and in Docker (where only dist/ is shipped). Add an asset entry for inertia/ in nest-cli.json (compilerOptions.assets) so nest build copies the shell template into dist/.

src/main.ts -- wire Vite after NestFactory.create:

const app = await NestFactory.create(AppModule);

const { setupInertiaVite } = await import('@dudousxd/nestjs-inertia-vite');
await setupInertiaVite(app, {
  mode: process.env.NODE_ENV ?? 'development',
  root: 'inertia',
  publicDir: 'dist/inertia/client',
  outDir: 'dist/inertia',
});

await app.listen(3000);

Done. Run nest start --watch and open http://localhost:3000. Verify the setup any time with nestjs-inertia doctor.


The HTML shell and its directives

The shell file created by init uses three special directives:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My App</title>
    @inertiaHead
  </head>
  <body>
    @inertia
    @vite('inertia/app.tsx')
  </body>
</html>
DirectiveWhat it emits
@inertia<div id="app"> plus <script data-page="app" type="application/json"> containing the XSS-safe-escaped page data
@inertiaHeadSSR <head> tags (title, meta) when SSR is enabled. Empty string in CSR-only mode
@vite('entry')In dev: HMR client + React Refresh preamble + entry script. In prod: hashed asset URL + CSS link from the Vite manifest

You write the <head> with your app's meta tags, fonts, and favicon. The lib handles everything else.


InertiaModule.forRoot() options

OptionTypeRequiredDescription
rootViewstring | (ctx) => stringYesHTML shell -- file path or render function
versionstring | () => stringNoAsset version -- auto-computed from Vite manifest if omitted (SHA-1 in prod, UUID in dev)
ssr{ enabled, bundlePath, throwOnError? }NoSSR bundle configuration
share(req) => Promise<Record>NoPer-request shared props injected into every page
csrf{ secret, cookieName, tokenContext? }NoCSRF cookie options
codegen{ enabled }NoEnable the dev-mode file watcher for auto-codegen

For the full options reference see the core package docs.

When version is omitted, the lib automatically computes it from the Vite manifest in production (SHA-1 hash) or generates a random UUID in development. You only need to set version explicitly if you have a custom versioning strategy.


Adding a typed API endpoint

The codegen reads types directly from standard NestJS decorators. No extra schemas or contracts needed. Use the DTOs and decorators you already have:

// src/posts.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { ApiResponse } from '@nestjs/swagger';
import { As } from '@dudousxd/nestjs-inertia-client';
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';

class ListPostsQuery {
  @IsOptional()
  @IsNumber()
  @Type(() => Number)
  page?: number;
}

class PostDto {
  @IsString()
  id: string;

  @IsString()
  title: string;

  @IsString()
  publishedAt: string;
}

@Controller('/api/posts')
@As('posts')
export class PostsController {
  @Get()
  @ApiResponse({ type: [PostDto] })
  list(@Query() query: ListPostsQuery) {
    return [
      { id: '1', title: 'Hello World', publishedAt: '2026-01-01' },
    ];
  }
}

The codegen reads:

DecoratorWhat it extracts
@Controller('/api/posts') + @Get()HTTP method + path
@Query() query: ListPostsQueryQuery type from the DTO class
@ApiResponse({ type: [PostDto] })Response type
@As('posts') + method name listRoute name posts.list

No Zod, no defineContract, no @ApplyContract. Just standard NestJS.

No return type annotation needed. The codegen uses ReturnType<import(...)> to infer the response type directly from the controller method. Whether you annotate list(): Promise<PostDto[]> or let TypeScript infer it, the generated types are correct.

Explicit Zod schemas

If you prefer explicit Zod schemas (runtime validation + type extraction), see the defineContract + @ApplyContract docs. Both paths work -- DTOs are zero-config, Zod is explicit.


Codegen

Run pnpm nestjs-codegen --watch alongside nest start --watch to regenerate types on every controller/page change. InertiaModule can also auto-start a watcher on bootstrap in dev mode — see Auto-watch for the exact conditions and a caveat about the package it currently targets. Either way, codegen is disabled automatically in production.

On startup the watcher emits files into .nestjs-inertia/ at the project root:

your-project/
├── .nestjs-inertia/
│   ├── pages.d.ts     ← typed page component map
│   ├── routes.ts      ← Route.* and Path.* typed route constants
│   ├── api.ts         ← queryOptions(), mutationOptions(), queryKey() for every route
│   └── index.d.ts     ← barrel re-export

For CI or Docker builds, run codegen explicitly before TypeScript compilation:

pnpm exec nestjs-codegen
pnpm exec tsc --noEmit
pnpm run build

Docker deployments

If your Dockerfile only copies dist/ to the runtime image, the shell template (e.g. inertia/index.html) won't be available. Add an asset entry for inertia/ in nest-cli.json so nest build copies it into dist/, and write rootView with resolve(__dirname, ...) so the path resolves correctly from the compiled output. Run nestjs-inertia doctor to verify your setup.

See the Codegen guide for full configuration and generated file reference.


Using the generated API

The generated api.ts provides queryOptions(), mutationOptions(), and queryKey() for every route -- powered by TanStack Query.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '~codegen/api';

// Queries
const { data } = useQuery(api.posts.list.queryOptions());
const { data } = useQuery(api.posts.list.queryOptions({ page: 2 }));

// Mutations
const create = useMutation(api.posts.create.mutationOptions());
await create.mutateAsync({ title: 'Hello', body: '...' });

// Cache invalidation (typed key)
const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: api.posts.list.queryKey() });

Response types are inferred automatically from the controller method via ReturnType<import(...)>.


Wire the route resolver in your frontend entry, then use <Link> in any page component.

Boot wiring -- inertia/app.tsx:

import { InertiaRouteProvider } from '@dudousxd/nestjs-inertia-client/react';
import { route } from '~codegen/routes';

// Inside setup:
createRoot(el!).render(
  <InertiaRouteProvider routes={route}>
    <App {...props} />
  </InertiaRouteProvider>,
);

Usage -- any page component:

import { Link } from '@dudousxd/nestjs-inertia-client/react';

<Link route="posts.list">All Posts</Link>
<Link route="posts.show" routeParams={{ id: '42' }}>Post #42</Link>
<Link route="posts.list" query={{ page: 2 }}>Page 2</Link>

Boot wiring -- inertia/app.ts:

import { INERTIA_ROUTES_KEY } from '@dudousxd/nestjs-inertia-client/vue';
import { route } from '~codegen/routes';

// Inside setup:
app.provide(INERTIA_ROUTES_KEY, route);

Usage -- any page component:

<script setup lang="ts">
import { Link } from '@dudousxd/nestjs-inertia-client/vue';
</script>
<template>
  <Link route="posts.list">All Posts</Link>
  <Link route="posts.show" :route-params="{ id: '42' }">Post #42</Link>
</template>

Boot wiring -- inertia/app.ts:

import { route } from '~codegen/routes';
const appContext = new Map([['inertia-routes', route]]);
// In setup: mount(App, { target: el, props, context: appContext });

Usage -- any page component:

<script lang="ts">
import { Link } from '@dudousxd/nestjs-inertia-client/svelte';
</script>
<Link route="posts.list">All Posts</Link>

See the Typed Link guide for full <Link> props and programmatic navigation.


Type helpers

The generated api.ts exports a Route namespace with helper types for extracting request/response shapes from any named endpoint:

import type { Route } from '~codegen/api';

type PostList       = Route.Response<'posts.list'>;
type PostListQuery  = Route.Query<'posts.list'>;
type PostShowParams = Route.Params<'posts.show'>;

These types are derived from your controller method signatures via ReturnType<import(...)> and stay in sync automatically.

See the Typed Client guide for createFetcher and SSR hydration.


Testing

nestjs-inertia-testing provides expectInertia() -- a chainable assertion helper for Inertia responses.

// src/home.controller.spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { expectInertia } from '@dudousxd/nestjs-inertia-testing';
import { AppModule } from './app.module.js';

describe('HomeController', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = moduleRef.createNestApplication();
    await app.init();
  });

  afterAll(() => app.close());

  it('renders the Home component', async () => {
    const res = await request(app.getHttpServer())
      .get('/')
      .set('X-Inertia', 'true')
      .set('X-Inertia-Version', '1');

    expectInertia(res).toRenderComponent('Home');
  });
});
AssertionDescription
.toRenderComponent(name)Asserts the component field matches the expected name
.toHaveProp(path, value)Deep-equality check on a dot-notation prop path
.toHaveProps(object)Partial deep-equality on the entire props object
.toHaveSharedProp(path, value)Checks a prop injected via share or req.inertia.share()

See the Testing guide for the full API, TestingModule harness, and faking shared props.


Production build

The init command prints the build:client script to add to package.json (add a build:ssr variant with vite build --ssr if you enable SSR). The full production build sequence:

pnpm run build:client   # vite build -- emits client bundle + manifest
pnpm run build:ssr      # vite build --ssr -- emits SSR bundle
nest build              # compiles TypeScript to dist/
NODE_ENV=production node dist/main.js

In production setupInertiaVite switches from dev-server middleware to static-file serving from publicDir. The asset version is auto-computed from the real Vite manifest (SHA-1 hash).


Project structure after init

my-nestjs-app/
├── src/
│   ├── main.ts                   ← NestJS bootstrap + setupInertiaVite (snippet printed by init)
│   ├── app.module.ts             ← InertiaModule.forRoot(...) (snippet printed by init)
│   └── home.controller.ts        ← your @Inertia('Home') handler
├── inertia/
│   ├── app.tsx                   ← createInertiaApp entry (created by init)
│   ├── index.html                ← HTML shell with Inertia page script (created by init)
│   └── pages/
│       └── Home.tsx              ← your page components
├── .nestjs-inertia/              ← generated (gitignored)
│   ├── pages.d.ts
│   ├── routes.ts
│   └── api.ts
├── nestjs-codegen.config.ts      ← codegen config + nestjsInertiaCodegen() (created by init)
├── nestjs-inertia.d.ts           ← ambient declaration (created by init)
├── vite.config.ts                ← (created by init)
└── package.json

For clean imports between frontend, backend, and codegen output, add these path aliases.

tsconfig.json (or tsconfig.inertia.json if you have a separate frontend config):

{
  "compilerOptions": {
    "paths": {
      "@/*": ["src/*"],
      "~/*": ["inertia/*"],
      "~codegen/*": [".nestjs-inertia/*"]
    }
  }
}

vite.config.ts:

import { resolve } from 'node:path';

export default defineConfig({
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '~': resolve(__dirname, 'inertia'),
      '~codegen': resolve(__dirname, '.nestjs-inertia'),
    },
  },
});

This lets you write:

AliasImportResolves to
~codegen/import { api } from '~codegen/api'.nestjs-inertia/api
@/import { SomeService } from '@/some/service'src/some/service
~/import { MyComponent } from '~/components/MyComponent'inertia/components/MyComponent

Next steps

  • Codegen -- full codegen configuration, CI integration, and generated file reference
  • Typed Client -- createFetcher, SSR hydration, and client-side contract usage
  • Typed Link -- <Link> props, routeParams inference, and programmatic navigation
  • Testing -- expectInertia full API, TestingModule harness, and faking shared props
  • Multi-app -- InertiaModule.forFeature(...) for multiple independent frontends in one process
  • Core package -- full InertiaModule.forRoot options, InertiaService API, and partial-reload markers
  • Vite package -- nestInertia plugin options, setupInertiaVite options, and SSR build config
  • Recipe: Auth Redirect -- AuthRedirectGuard pattern for 302 vs. 409 redirect
  • Recipe: Not Found Filter -- exception filter that renders an Inertia page for 404s

On this page