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. The init command scaffolds all files and auto-patches your existing app.module.ts and main.ts. Most projects are done in under three minutes.


Prerequisites

  • Node.js 20+
  • NestJS 10+ (both v10 and v11 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-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-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-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 (or asks interactively), detects your template engine (Handlebars, EJS, Pug, Liquid, or plain HTML), and scaffolds everything in one shot.

Files created by init

FileWhat it is
nestjs-codegen.config.tsCodegen config (with nestjsInertiaCodegen() registered) and a framework-specific page glob
nestjs-inertia.d.tsTypeScript module augmentation for generated types
inertia/index.html (or .hbs/.ejs/.pug)HTML shell with the Inertia page script tag
vite.config.tsVite config with the correct framework plugin
inertia/app.tsx (or .ts)Frontend entry point bootstrapping createInertiaApp
inertia/pages/Home.tsx (or .vue/.svelte)Sample page component
src/home.controller.tsSample NestJS controller with @Inertia('Home')

Files patched by init

FilePatch
src/app.module.tsAdds InertiaModule.forRoot(...) to imports and HomeController to controllers
src/main.tsInserts setupInertiaVite(app, {...}) after NestFactory.create
.gitignoreAppends .nestjs-inertia/
package.jsonAdds build:client and build:ssr scripts

Every create and patch is idempotent. Re-running init on an existing project skips files that already exist and skips patches already applied.

If vite.config.ts exists but doesn't reference the nestInertia plugin, a warning is printed instead of overwriting your config.

Done. Run nest start --watch and open http://localhost:3000.


Step 3 -- Manual setup (if auto-patching fails)

If init cannot find or parse your app.module.ts or main.ts (non-standard structure), it prints a warning with the exact snippet to add manually.

src/app.module.ts -- register InertiaModule and HomeController:

import { resolve } from 'node:path';
import { InertiaModule } from '@dudousxd/nestjs-inertia';
import { HomeController } from './home.controller.js';

@Module({
  imports: [
    InertiaModule.forRoot({
      rootView: resolve(__dirname, '../inertia/index.html'),
    }),
  ],
  controllers: [HomeController],
})
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). The init command also patches nest-cli.json to copy the shell template into dist/ during build.

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);

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

Codegen runs automatically. When @dudousxd/nestjs-codegen is installed and a nestjs-codegen.config.ts is present, the watcher starts on nest start --watch and regenerates types on every controller/page change. 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-inertia 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. The init command patches nest-cli.json to copy it into dist/ during nest build, and generates 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 adds build:client and build:ssr scripts to package.json. 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 (patched by init)
│   ├── app.module.ts             ← InertiaModule.forRoot(...) (patched by init)
│   └── home.controller.ts        ← @Inertia('Home') handler (created by init)
├── inertia/
│   ├── app.tsx                   ← createInertiaApp entry (created by init)
│   ├── index.html                ← HTML shell with Inertia page script (created by init)
│   └── pages/
│       └── Home.tsx              ← sample page (created by init)
├── .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