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-testingnpm 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-testingyarn 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-testingCodegen 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-reactpnpm add @inertiajs/vue3@^3.0.0 vue
pnpm add -D @vitejs/plugin-vuepnpm add @inertiajs/svelte@^3.0.0 svelte
pnpm add -D @sveltejs/vite-plugin-svelteStep 2 -- Run nestjs-inertia init
pnpm exec nestjs-inertia initnpx nestjs-inertia inityarn nestjs-inertia initThis 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
| File | What it is |
|---|---|
nestjs-codegen.config.ts | Shared 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.ts | Stub for handwritten registry augmentations (codegen writes the generated ones into .nestjs-inertia/pages.d.ts) |
inertia/index.html | HTML shell with the @inertia / @inertiaHead / @vite directives |
inertia/app.tsx (or .ts) | Frontend entry point bootstrapping createInertiaApp |
vite.config.ts | Vite config with the framework plugin + nestInertia plugin + @/~/~codegen aliases |
.gitignore | Created 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>| Directive | What it emits |
|---|---|
@inertia | <div id="app"> plus <script data-page="app" type="application/json"> containing the XSS-safe-escaped page data |
@inertiaHead | SSR <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
| Option | Type | Required | Description |
|---|---|---|---|
rootView | string | (ctx) => string | Yes | HTML shell -- file path or render function |
version | string | () => string | No | Asset version -- auto-computed from Vite manifest if omitted (SHA-1 in prod, UUID in dev) |
ssr | { enabled, bundlePath, throwOnError? } | No | SSR bundle configuration |
share | (req) => Promise<Record> | No | Per-request shared props injected into every page |
csrf | { secret, cookieName, tokenContext? } | No | CSRF cookie options |
codegen | { enabled } | No | Enable 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:
| Decorator | What it extracts |
|---|---|
@Controller('/api/posts') + @Get() | HTTP method + path |
@Query() query: ListPostsQuery | Query type from the DTO class |
@ApiResponse({ type: [PostDto] }) | Response type |
@As('posts') + method name list | Route 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-exportFor CI or Docker builds, run codegen explicitly before TypeScript compilation:
pnpm exec nestjs-codegen
pnpm exec tsc --noEmit
pnpm run buildDocker 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(...)>.
Typed <Link> component
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');
});
});| Assertion | Description |
|---|---|
.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.jsIn 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.jsonRecommended tsconfig aliases
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:
| Alias | Import | Resolves 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,routeParamsinference, and programmatic navigation - Testing --
expectInertiafull API,TestingModuleharness, and faking shared props - Multi-app --
InertiaModule.forFeature(...)for multiple independent frontends in one process - Core package -- full
InertiaModule.forRootoptions,InertiaServiceAPI, and partial-reload markers - Vite package --
nestInertiaplugin options,setupInertiaViteoptions, and SSR build config - Recipe: Auth Redirect --
AuthRedirectGuardpattern for 302 vs. 409 redirect - Recipe: Not Found Filter -- exception filter that renders an Inertia page for 404s