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-testingnpm 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-testingyarn 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-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 (or asks interactively), detects your template engine (Handlebars, EJS, Pug, Liquid, or plain HTML), and scaffolds everything in one shot.
Files created by init
| File | What it is |
|---|---|
nestjs-codegen.config.ts | Codegen config (with nestjsInertiaCodegen() registered) and a framework-specific page glob |
nestjs-inertia.d.ts | TypeScript module augmentation for generated types |
inertia/index.html (or .hbs/.ejs/.pug) | HTML shell with the Inertia page script tag |
vite.config.ts | Vite 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.ts | Sample NestJS controller with @Inertia('Home') |
Files patched by init
| File | Patch |
|---|---|
src/app.module.ts | Adds InertiaModule.forRoot(...) to imports and HomeController to controllers |
src/main.ts | Inserts setupInertiaVite(app, {...}) after NestFactory.create |
.gitignore | Appends .nestjs-inertia/ |
package.json | Adds 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>| 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
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-exportFor CI or Docker builds, run codegen explicitly before TypeScript compilation:
pnpm exec nestjs-inertia 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. 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(...)>.
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 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.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 (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.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