@dudousxd/nestjs-inertia
v1.5.0
Published
Inertia.js adapter for NestJS — core protocol and module.
Downloads
3,984
Maintainers
Readme
@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.
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 adapterQuick 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, liquidjsInertiaModule.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 inX-Inertia-Partial-Datadefer— listed inpage.deferredProps; client v2 dispatches a follow-up requestmerge— resolves + marks as merge target (client appends/replaces)once— resolves on first visit, cached untilX-Inertia-Reset-Oncelists the keylazy— alias foroptional(v1 compat)
Auto-included infrastructure
forRoot() installs:
InertiaMiddleware(Express) orFastifyInertiaPlugin(Fastify) —req.inertiaavailable everywhereMethodSpoofMiddleware(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 {
InertiaAuthGuard,
InertiaNotFoundFilter,
ErrorBagInterceptor,
} from '@dudousxd/nestjs-inertia';
// Auth guard — applies per controller / handler
@UseGuards(new InertiaAuthGuard({ signInUrl: '/signin', allowList: ['/signin/*'] }))
// Not-found filter — register globally
app.useGlobalFilters(new InertiaNotFoundFilter({ apiPrefix: '/api', component: 'NotFound' }));
// Error bag interceptor — opt-in per route
@UseInterceptors(ErrorBagInterceptor)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:
NODE_ENV !== 'production'@dudousxd/nestjs-inertia-codegenis installed (peer-optional — silently skipped if absent)nestjs-inertia.config.tsis present at the project rootNESTJS_INERTIA_DISABLE_AUTO_CODEGENis 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 liveIf 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
