Skip to content

Getting Started

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 — no manual wiring needed. Most projects are done in under three minutes.


Install the five packages that make up the suite, then add the Inertia client adapter for your frontend framework.

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

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

Terminal window
pnpm add @inertiajs/react react react-dom
pnpm add -D @types/react @types/react-dom @vitejs/plugin-react

Terminal window
pnpm exec nestjs-inertia init

This command scaffolds your entire Inertia setup in one shot. It:

  • Detects your frontend framework from package.json (React, Vue 3, or Svelte 5). If none is found it asks interactively.
  • Detects your template engine — Handlebars, EJS, Pug, Liquid, or plain HTML.
  • Creates scaffold files — skips any that already exist (fully idempotent). If vite.config.ts exists but doesn’t reference the nestInertia plugin, a warning is printed instead of overwriting your config:
FileWhat it is
nestjs-inertia.config.tsCodegen config with 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')
  • Intelligently patches existing files — each patch is only applied if the content is not already present:
    • src/app.module.ts — adds InertiaModule.forRoot(...) to imports and HomeController to controllers
    • src/main.ts — inserts the setupInertiaVite(app, {...}) call right after NestFactory.create
    • .gitignore — appends .nestjs-inertia/ if not already present
    • package.json scripts — adds build:client and build:ssr only if not already defined

Expected output (fresh project):

nestjs-inertia init
Detected: React + plain HTML
Scaffold files
✓ nestjs-inertia.config.ts (created)
✓ nestjs-inertia.d.ts (created)
✓ inertia/index.html (created)
✓ vite.config.ts (created)
✓ inertia/app.tsx (created)
✓ inertia/pages/Home.tsx (created)
✓ src/home.controller.ts (created)
Patch existing files
✓ src/app.module.ts (added InertiaModule.forRoot)
✓ src/app.module.ts (added HomeController to controllers)
✓ src/main.ts (added setupInertiaVite after NestFactory.create)
✓ .gitignore (added .nestjs-inertia/)
✓ package.json (added build:client script)
✓ package.json (added build:ssr script)
Install dependencies
✓ @inertiajs/react, react, react-dom (installed)
✓ Setup complete! Run: nest start --watch

Re-running init on an existing project skips files that already exist and skips patches already applied — it is fully idempotent:

Scaffold files
→ nestjs-inertia.config.ts (already exists, skipped)
→ inertia/index.html (already exists, skipped)
→ vite.config.ts (already exists, skipped)
...
Patch existing files
→ src/app.module.ts (InertiaModule already registered, skipped)
→ src/main.ts (setupInertiaVite already present, skipped)
→ .gitignore (already contains .nestjs-inertia/, skipped)
→ package.json (build:client already defined, skipped)

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


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

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

import { InertiaModule } from '@dudousxd/nestjs-inertia';
import { HomeController } from './home.controller.js';
@Module({
imports: [
InertiaModule.forRoot({
rootView: 'inertia/index.html',
}),
],
controllers: [HomeController],
})
export class AppModule {}

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 shell file created by init uses three special directives. These are the key to how nestjs-inertia works.

<!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"></div> plus <script type="application/json">{page data}</script>. The page JSON is XSS-safe escaped.
@inertiaHeadSSR <head> tags (title, meta) when SSR is enabled. Empty string in CSR-only mode.
@vite('entry')In dev: <script src="/@vite/client"> (HMR) + React Refresh preamble + entry script. In prod: reads the Vite manifest and emits the hashed asset URL + CSS link.

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


The sections below cover the full configuration surface for each piece. They are optional reading — the quickstart above is all you need to get running.


OptionTypeRequiredDescription
versionstring | () => stringNoAsset version — auto-computed from Vite manifest if omitted (SHA-1 in prod, UUID in dev)
rootViewstring | (ctx) => stringYesHTML shell — file path or render function
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.

Version auto-detection: 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.


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:

  • @Controller('/api/posts') + @Get() → HTTP method + path
  • @Query() query: ListPostsQuery → query type from the DTO class
  • @ApiResponse({ type: [PostDto] }) → response type
  • @As('posts') → class namespace; method name list → route name posts.list

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

Advanced: 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 runs automatically — no config needed. When @dudousxd/nestjs-inertia-codegen is installed and a nestjs-inertia.config.ts is present, the watcher starts on nest start --watch and regenerates types on every controller/page change. It’s 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 ← typed API surface from controller DTOs + @ApiResponse
│ └── index.d.ts ← barrel re-export

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

Terminal window
pnpm exec nestjs-inertia codegen
pnpm exec tsc --noEmit
pnpm run build

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


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

// inertia/app.tsx — add InertiaRouteProvider
import { InertiaRouteProvider } from '@dudousxd/nestjs-inertia-client/react';
import { route } from '../.nestjs-inertia/routes.js';
// Inside setup:
createRoot(el!).render(
<InertiaRouteProvider routes={route}>
<App {...props} />
</InertiaRouteProvider>,
);
// In 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>

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


The generated api.ts exports a Route namespace with helper types that let you extract request/response shapes for any named endpoint.

import type { Route } from '../.nestjs-inertia/api';
type PostList = Route.Response<'posts.list'>;
type PostListQuery = Route.Query<'posts.list'>;
type PostShowParams = Route.Params<'posts.show'>;

These types are derived entirely from the Zod schemas passed to defineContract — they stay in sync automatically.

See the Typed Client guide for createFetcher and SSR hydration.


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.


The init command adds build:client and build:ssr scripts to package.json. The full production build sequence:

Terminal window
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 computeAssetVersion factory (shown in the forRoot section above) reads the real manifest and returns a stable hash.


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-inertia.config.ts ← codegen config (created by init)
├── nestjs-inertia.d.ts ← ambient declaration (created by init)
├── vite.config.ts ← (created by init)
└── package.json

  • Guides: Codegen — full codegen configuration, CI integration, and generated file reference
  • Guides: Typed ClientcreateFetcher, SSR hydration, and client-side contract usage
  • Guides: Typed Link<Link> props, routeParams inference, and programmatic navigation
  • Guides: TestingexpectInertia full API, TestingModule harness, and faking shared props
  • Guides: Multi-appInertiaModule.forFeature(...) for multiple independent frontends in one process
  • Packages: Core — full InertiaModule.forRoot options, InertiaService API, and partial-reload markers
  • Packages: VitenestInertia plugin options, setupInertiaVite options, and SSR build config
  • Recipes: Auth RedirectInertiaAuthGuard pattern for 302 vs. 409 redirect on unauthenticated Inertia requests
  • Recipes: Not Found Filter — exception filter that renders an Inertia page for 404s instead of JSON