@dudousxd/nestjs-inertia

Inertia.js adapter for NestJS — core protocol and module.
11,125 4
Install
npm install @dudousxd/nestjs-inertia@3.0.0
Latest Version:3.0.0
License:MIT
Last Updated:Oct 21, 2026
Links: GitHub  ·  npm
Maintainer: dudousxd

@dudousxd/nestjs-inertia

Inertia.js v3 adapter for NestJS — Express + Fastify, multi-app via forFeature, 4 template engines, CSRF protection, full Inertia v3 protocol parity.

npm license

Install

pnpm add @dudousxd/nestjs-inertia

# Pick your HTTP platform
pnpm add express @types/express          # Express adapter (default)
# OR
pnpm add fastify @nestjs/platform-fastify @fastify/cookie  # Fastify adapter

Quick start

// app.module.ts
import { Module } from '@nestjs/common';
import { InertiaModule } from '@dudousxd/nestjs-inertia';

@Module({
  imports: [
    InertiaModule.forRoot({
      version: () => process.env.ASSET_VERSION ?? 'dev',
      rootView: 'inertia/root.html',
      share: async (req) => ({ auth: req.user ?? null }),
    }),
  ],
})
export class AppModule {}

inertia/root.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>My App</title>
  @inertiaHead
  @vite('app/client.tsx')
  @viteRefresh
</head>
<body>
  @inertia
</body>
</html>

Controllers

Two equivalent patterns coexist:

import { Controller, Get, Req } from '@nestjs/common';
import { Inertia } from '@dudousxd/nestjs-inertia';
import type { Request } from 'express';

@Controller()
export class HomeController {
  // Decorator pattern (idiomatic for new code)
  @Get('/')
  @Inertia('Home')
  show() {
    return { hello: 'world' };
  }

  // Imperative pattern (use when you need fine control)
  @Get('/crew')
  async list(@Req() req: Request) {
    await req.inertia
      .share({ flash: req.session?.flash ?? {} })
      .render('Crew', { crew: await this.svc.list() });
  }
}

Async config

InertiaModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (cfg: ConfigService) => ({
    version: cfg.get('ASSET_VERSION'),
    rootView: 'inertia/root.html',
  }),
});

Three paths: useFactory + inject (most common), useClass, useExisting.

Multi-app with forFeature

Host two or more Inertia apps in the same NestJS process — each with its own Vite entry, shell, version, share, SSR bundle. Useful for admin panels, multi-tenant white-label, or migrations between frontend stacks.

@Module({
  imports: [
    InertiaModule.forRoot({                  // main app
      vite: { entry: 'app/client.tsx' },
      rootView: 'inertia/root.html',
      share: req => ({ auth: req.user }),
    }),
    InertiaModule.forFeature({               // admin app
      scope: 'admin',
      vite: { entry: 'admin/client.tsx' },
      rootView: 'inertia/admin-root.html',
      share: req => ({ admin: req.adminContext }),
    }),
  ],
})
export class AppModule {}

Select scope per controller / method with @UseInertia('scope'):

@Controller('admin')
@UseInertia('admin')
export class AdminDashboardController {
  @Get('/')
  @Inertia('AdminDashboard')
  show() { return { stats: ... }; }
}

forFeatureAsync works the same as forRootAsync (useFactory/useClass/useExisting). Reserved scope: 'default' is owned by forRoot().

Template engines

rootView accepts .html (own parser) plus .hbs / .ejs / .pug / .liquid if the engine package is installed:

pnpm add handlebars                    # or: ejs, pug, liquidjs
InertiaModule.forRoot({
  rootView: 'inertia/root.hbs',         // auto-detects Handlebars
});

Each engine sees locals { page, inertia, inertiaHead, vite, viteRefresh, asset }. Use the engine's own escape rules (e.g., {{{inertia}}} triple-stache in Handlebars, <%- inertia %> in EJS, != inertia in Pug). The @inertia/@vite/@asset directives are also processed on the engine's output, so you can mix and match.

CSRF

import { CsrfCookieInterceptor, CsrfGuard } from '@dudousxd/nestjs-inertia';

// Global cookie writer
app.useGlobalInterceptors(new CsrfCookieInterceptor({ secret: process.env.CSRF_SECRET }));

// Per-route validation
@UseGuards(new CsrfGuard({ secret: process.env.CSRF_SECRET }))
@Post('/profile')
async update() { ... }

Cookie name XSRF-TOKEN, header name X-XSRF-TOKEN — both match the Inertia client convention. Signed via HMAC-SHA256. Requires cookie-parser (Express) or @fastify/cookie (Fastify) as peer dep.

Fastify

import { FastifyAdapter } from '@nestjs/platform-fastify';

const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());

Full feature parity with Express: middleware, decorator, all interceptors, guards, filters, shell directives, SSR, FlashStore. request.inertia is wired via decorateRequest + onRequest hook automatically when the FastifyAdapter is detected.

Prop markers

import { Inertia } from '@dudousxd/nestjs-inertia';

return {
  user: Inertia.always(() => currentUser()),
  stats: Inertia.optional(() => heavyStatsCalculation()),
  activity: Inertia.defer(() => activityFeed(), 'secondary'),
  rows: Inertia.merge(() => paginated(p), { matchOn: 'id', deep: false }),
  csrfToken: Inertia.once(() => generateToken()),
};
  • always — resolves on every render (including partial reloads)
  • optional — resolves only when listed in X-Inertia-Partial-Data
  • defer — listed in page.deferredProps; client v2 dispatches a follow-up request
  • merge — resolves + marks as merge target (client appends/replaces)
  • once — resolves on first visit, cached until X-Inertia-Reset-Once lists the key
  • lazy — alias for optional (v1 compat)

Auto-included infrastructure

forRoot() installs:

  • InertiaMiddleware (Express) or FastifyInertiaPlugin (Fastify) — req.inertia available everywhere
  • MethodSpoofMiddleware (POST + multipart + _method=PUT/PATCH/DELETE)
  • RedirectInterceptor (302 → 303 upgrade on PUT/PATCH/DELETE Inertia requests)
  • InertiaRenderInterceptor (handles @Inertia('Page') decorator)
  • InertiaScopeSwitcherInterceptor (handles @UseInertia('scope'))

Disable via knobs:

InertiaModule.forRoot({
  methodSpoofing: false,
  autoUpgrade303: false,
});

Opt-in utilities

import { ErrorBagInterceptor } from '@dudousxd/nestjs-inertia';

// Error bag interceptor — opt-in per route
@UseInterceptors(ErrorBagInterceptor)

Auth-redirect guards and not-found filters are not shipped exports — build your own following the auth-redirect and not-found recipes.

FlashStore (errors)

NestJS has no session of its own. Plug an adapter:

import type { FlashStore } from '@dudousxd/nestjs-inertia';

class ExpressSessionFlashStore implements FlashStore {
  read(req) {
    return (req as Request).session?.flash?.errors ?? {};
  }
}

InertiaModule.forRoot({
  flashStore: new ExpressSessionFlashStore(),
});

SSR

InertiaModule.forRoot({
  ssr: {
    enabled: process.env.NODE_ENV === 'production',
    bundlePath: 'dist/inertia/ssr/ssr.mjs',
    throwOnError: false,
  },
});

Bundle must export default { render(page) } or named render(page) returning { head: string[], body: string }.

Codegen auto-watch (dev mode)

When @dudousxd/nestjs-inertia-codegen is installed and a nestjs-inertia.config.ts config file is present, InertiaModule automatically starts the codegen file watcher when your app bootstraps — so nest start --watch is the only command you need in dev mode. Generated files appear under .nestjs-inertia/ and update on every controller or page save. No extra command, no extra terminal.

Auto-watch starts when all of these are true:

  1. NODE_ENV !== 'production'
  2. @dudousxd/nestjs-inertia-codegen is installed (peer-optional — silently skipped if absent)
  3. nestjs-inertia.config.ts is present at the project root
  4. NESTJS_INERTIA_DISABLE_AUTO_CODEGEN is not set to '1'

Running the CLI watcher manually: pnpm nestjs-inertia codegen --watch in a separate terminal gives you explicit control. When both the auto-watcher and the CLI watcher run at the same time, only one holds the lock and generates files; the other logs a warning and becomes a no-op. Stale locks from crashed processes are detected via PID-liveness check and overwritten automatically.

No files generated? Check that the app finished booting. The auto-watch starts in onApplicationBootstrap — it runs only after every module finishes initializing. A boot that stalls mid-init (a hung provider factory, an unreachable dependency like a dead local SQS/Redis container, etc.) produces no codegen output and no codegen error. The log tells you which case you're in:

[InertiaModule] Codegen auto-watch will start after application bootstrap. ...   ← logged early (module init)
[InertiaModule] Codegen auto-watch started (dev mode).                           ← logged once the watcher is live

If you see the first line but never the second, the app hasn't finished booting — fix the stalled dependency (or run pnpm nestjs-inertia codegen for a one-shot generation in the meantime).

Disable auto-watch (CI or explicit control):

InertiaModule.forRoot({
  codegen: { enabled: false },
});

Optional nest-cli.json snippet — only useful if your server bundle imports generated files:

{
  "compilerOptions": {
    "assets": [".nestjs-inertia/**/*"],
    "watchAssets": true
  }
}

Protocol parity

Full Inertia protocol: X-Inertia headers, version mismatch (409 + X-Inertia-Location, GET only), partial reloads, deferred props, merge/deepMerge with matchOn, once, history encryption / clear, error bags, X-Inertia-Reset, X-Inertia-Partial-Except, X-Inertia-Reset-Once, dot-notation unpacking, undefined→null wire conversion.

Companion packages (planned)

  • @dudousxd/nestjs-inertia-vite — Vite dev/build helpers (Plan B)
  • @dudousxd/nestjs-inertia-testing — expectInertia(res) matchers (Plan B)
  • @dudousxd/nestjs-inertia-codegen — typed pages (Plan C)
  • @dudousxd/nestjs-inertia-client — Tuyau-style typed REST + TanStack Query (Plan D)
  • Examples + docs site + CI workflows (Plan E)

See docs/design.md for full design.

License

MIT © Davi Carvalho

Related Packages

@forinda/kickjs-inertia

Inertia.js adapter for KickJS — server-driven pages rendered by React, Vue or Sv...

448 17
create-dulak

Scaffold a new Dulak project — Hono + bun:sqlite + Inertia v3 boilerplate with t...

0 8
@arkstack/inertia

InertiaJS server-side adapter for Arkstack, building the modern monolith with se...

12,191 6
csszyx

Universal CSS-in-JS for Tailwind CSS with WASM core

10,845 0
@tanstack/table-core

Headless UI for building powerful tables & datagrids for TS/JS.

656,578,845 28,508

Version History

Version Released License
3.0.0 MIT
2.0.1 MIT
2.0.0 MIT
1.9.1 MIT
1.9.0 MIT