nestjs-inertia
Packages

@dudousxd/nestjs-inertia (core)

Core NestJS module, @Inertia decorator, InertiaService, and version negotiation.

pnpm add @dudousxd/nestjs-inertia
npm install @dudousxd/nestjs-inertia
yarn add @dudousxd/nestjs-inertia

Implements the Inertia.js protocol as a NestJS module. Handles page rendering, version negotiation, shared props, SSR, CSRF, and redirects.

Exports

ExportDescription
InertiaModule.forRoot(options)Global module registration; sets root view, version, shared props
InertiaModule.forRootAsync(options)Async variant (useFactory, useClass, useExisting)
InertiaModule.forFeature(options)Register an additional scope with its own view/shared props
InertiaModule.forFeatureAsync(options)Async variant for feature scopes
@Inertia(component)Method decorator; intercepts return value, renders Inertia response
@UseInertia(scope)Controller/method decorator; selects a forFeature scope
InertiaServiceInjectable service for programmatic renders and redirects
Inertia.always(fn)Prop marker; always resolved, even on partial reloads
Inertia.optional(fn)Prop marker; resolved only when explicitly requested
Inertia.defer(fn, group?)Prop marker; deferred to a separate request
Inertia.merge(fn, opts?)Prop marker; appended/prepended on reload
Inertia.once(fn, opts?)Prop marker; resolved once, then cached client-side
Inertia.scroll(fn, opts?)Prop marker; infinite-scroll pagination — inner data merged across visits with an announced cursor
InertiaValidationFilterException filter; flashes a field-keyed error bag and 303-redirects back on validation failure (Forms & Validation)
inertiaValidationExceptionFactoryValidationPipe exception factory that preserves nested field paths
ErrorBagInterceptorNamespaces errors by X-Inertia-Error-Bag header
CsrfCookieInterceptorSets XSRF-TOKEN cookie and validates X-XSRF-TOKEN
CsrfGuardGuard-based CSRF validation
generateCsrfToken / verifyCsrfToken / rotateCsrfTokenStandalone HMAC CSRF token functions (see CSRF)
RedirectInterceptorConverts 302 to 303 for PUT/PATCH/DELETE per Inertia spec
MethodSpoofMiddleware_method field support for HTML form method spoofing

Minimal setup

import { resolve } from 'node:path';

InertiaModule.forRoot({
  rootView: resolve(__dirname, '../inertia/root.html'),
  version: '1',
})

See the Getting Started guide for a full bootstrap example.

Page props on the wire

Page props are serialized with JSON.stringify, so every value arrives in the shape JSON.stringify gives it: a Date becomes an ISO string, a class with a toJSON (Decimal, Luxon, ORM entities) sends its toJSON() result, and Map / Set become {} as they would in any JSON response.

Markers can sit at any depth, as long as every container on the way to them is an array or a plain object (prototype Object.prototype or null). Any other object (a Date, a DTO or entity instance, a Map) is treated as a leaf: the resolver does not look inside it, so a marker placed in a class instance is sent unresolved. Spread the instance into a plain object when it needs a nested marker:

// Resolved: `feed` sits in a plain object
return { user: { ...dto, feed: Inertia.defer(() => this.feed()) } };

// Not resolved: `feed` sits inside a class instance
return { user: Object.assign(new UserDto(), { feed: Inertia.defer(() => this.feed()) }) };

The same container rule applies elsewhere in the render path:

  • Dot-path partial reloads (only: ['user.name']) narrow plain objects and arrays. When the requested path runs into a class instance, that instance is sent whole.
  • undefined → null: undefined values in plain objects, arrays and class instances without a toJSON are sent as null. Objects with a toJSON are left for their toJSON to serialize.
  • Cost: a prop with no marker inside it, and no dot-path filter on it, is sent as returned, without being copied. When a prop does hold markers, only the containers on the path to them are rebuilt.

Function props and marker factories run one at a time, in key order, so factories that share a request-scoped resource (for example a MikroORM EntityManager) never run concurrently. To run independent work in parallel, do it inside one factory with Promise.all.

Root views and template engines

rootView accepts an .html file (rendered by the built-in directive parser) or a .hbs / .ejs / .pug / .liquid template when the matching engine package (handlebars, ejs, pug, liquidjs) is installed. Engine templates receive the locals { page, inertia, inertiaHead, vite, viteRefresh, asset }; vite(entry) and asset(path) are functions returning the same markup as the directives. Output them with the engine's unescaped syntax ({{{inertia}}} in Handlebars, <%- inertia %> in EJS, != inertia in Pug).

Directives (@inertia, @inertiaHead, @vite('…'), @viteRefresh, @asset('…')) are expanded in a single pass. Text a directive inserts (the page JSON, SSR head and body) is never parsed for directives, so a prop whose text contains @vite('x') or @inertia is sent verbatim.

In an engine template, directives can be mixed with engine syntax anywhere in the rendered output: the root view, its partials and includes, and Pug attribute values. The engine renders first, with stand-ins for the page JSON and SSR head and body, and with @ masked in the strings of the page local; directives are then expanded over the engine output, and the masked @s, page JSON and SSR markup are restored last. Page data is never treated as a directive, whether it reaches the HTML through inertia or through text you print from page yourself (for example {{page.props.title}}).

InertiaService runtime methods

Inject InertiaService to drive a response programmatically. Beyond render(), it exposes the full set of Inertia protocol operations. The mutators (share, encryptHistory, clearHistory) return this, so they chain.

MethodEffect
share(input)Merge extra props into the page's shared props for this response (object or a resolver function). Chainable.
flash(data) / flash(key, value)Flash non-error data for the next request (Laravel/Rails-style). Persisted via the configured flashStore and surfaced as the flash shared prop on the following render, then cleared. No-ops when no flashStore is set.
location(url)Perform an Inertia redirect: for Inertia XHR it sends 409 with X-Inertia-Location; for a plain browser visit it sends 302 Location. Only relative or same-origin absolute URLs are accepted (open-redirect safe).
encryptHistory(value = true)Ask the client to encrypt this page's history entry (protects props cached in history.state). Chainable.
clearHistory()Tell the client to clear its encrypted history cache — call it after logout/session changes. Chainable.
account.controller.ts
@Controller('account')
export class AccountController {
  constructor(private readonly inertia: InertiaService) {}

  @Post('logout')
  async logout() {
    await this.inertia.flash('status', 'signed-out');
    // wipe any encrypted history, then bounce to /login (409 for XHR, 302 for a full visit)
    this.inertia.clearHistory().location('/login');
  }

  @Get('secret')
  async secret() {
    // encrypt this entry's history and add a per-response shared prop
    this.inertia
      .encryptHistory()
      .share({ banner: 'Confidential' });
    await this.inertia.render('account/secret', { balance: 4200 });
  }
}

flash() and the validation error bag both ride on the configured flashStore. Wire one up (any store implementing writeFlash / write) to enable both — see Forms & Validation.

CSRF

CsrfGuard and CsrfCookieInterceptor are the drop-in pair (issue an XSRF-TOKEN cookie, validate the X-XSRF-TOKEN header). Under them sits a standalone, DI-free HMAC token API you can call directly — for custom middleware, a login handler, or tests:

ExportSignaturePurpose
generateCsrfToken(secret, context?) => stringMint a token. With a context (e.g. session id), it's bound into the HMAC as raw.ctx.sig, so the token dies when the context changes.
verifyCsrfToken(token, secret, context?) => booleanConstant-time verify. Returns false (never throws) on any malformed/mismatched token.
rotateCsrfToken(res, options, context?) => voidWrite a fresh token cookie onto the response — call after a session principal change to prevent fixation.

Both CsrfGuardOptions and CsrfCookieOptions accept a secret, optional cookieName (default XSRF-TOKEN) / headerName (default X-XSRF-TOKEN), and an optional tokenContext: (req) => string — supply the same tokenContext to the guard and the interceptor so issued and verified tokens agree.

auth.controller.ts
import { generateCsrfToken, rotateCsrfToken, verifyCsrfToken } from '@dudousxd/nestjs-inertia';

// bind the token to the session so old tokens stop verifying after re-login
const token = generateCsrfToken(secret, req.session.id);
verifyCsrfToken(token, secret, req.session.id); // true

// after issuing a new session, rotate the cookie to kill fixation
rotateCsrfToken(res, { secret, sameSite: 'lax' }, req.session.id);
app.module.ts — the guard/interceptor pair
const tokenContext = (req: { session?: { id?: string } }) => req.session?.id ?? '';

{
  provide: APP_GUARD,
  useValue: new CsrfGuard({ secret: process.env.CSRF_SECRET!, tokenContext }),
}
// paired with a CsrfCookieInterceptor that shares the same secret + tokenContext

On this page