@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 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/@inertiaHead/@vite/@viteRefresh/@asset directives are also expanded in the engine's output, root view and partials/includes alike, so you can mix and match. The page JSON, the SSR markup and any text printed from page are restored after that pass, so page data is never treated as a directive.
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 key
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 { 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 }.
Generated files in the server bundle
Only if your server bundle imports generated files, copy them with nest-cli.json:
{
"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