Aviary
Integrations

nestjs-inertia

Pages, shared props, and Inertia router navigation.

When you build a NestJS + Inertia app, register the inertia extension and point pages.glob/app.moduleEntry at your app. The codegen then additionally:

  • discovers your Inertia pages and emits pages.d.ts + components.json;
  • reads shared props from InertiaModule.forRoot({ share });
  • emits a typed navigate() helper backed by the Inertia router.
pnpm add -D @dudousxd/nestjs-inertia-codegen-extension
nestjs-codegen.config.ts
import { defineConfig } from '@dudousxd/nestjs-codegen';
import { nestjsInertiaCodegen } from '@dudousxd/nestjs-inertia-codegen-extension';

export default defineConfig({
  pages: { glob: 'resources/pages/**/*.tsx' },
  contracts: { glob: 'src/**/*.controller.ts' },
  app: { moduleEntry: 'src/app.module.ts' },
  codegen: { outDir: 'resources/generated' },
  extensions: [nestjsInertiaCodegen()],
});

Typed navigation

import { navigate } from '../generated/api';

navigate('users.show', { params: { id: '42' } }); // typed route + params

When nestjsInertiaCodegen() is registered, the generated api.ts imports router from @inertiajs/react for this helper; without the extension it isn't imported at all.

The pages typing augments @dudousxd/nestjs-inertia's InertiaPages interface, so inertia.render('Users/Index', props) is checked against the discovered page props.

Shared props

By default, the codegen statically analyzes InertiaModule.forRoot({ share }) in app.moduleEntry and augments InertiaSharedProps (declaration-merged into @dudousxd/nestjs-inertia) directly in pages.d.ts. That works as long as share is a plain function whose return type can be resolved from that one call site.

When shared props are registered per-request instead — e.g. req.inertia.share(...) in middleware, which the static analysis above can't see — pass shared to nestjsInertiaCodegen() to declare the source explicitly:

nestjs-codegen.config.ts
extensions: [
  nestjsInertiaCodegen({
    shared: { module: 'src/middleware/inertia-share', export: 'buildSharedProps', kind: 'function' },
  }),
],

This emits a standalone shared.ts re-exporting a type-only InertiaSharedProps, so no runtime import of server code leaks into the frontend build:

  • kind: 'function' — the export is a factory (e.g. the share() callback); the type is Awaited<ReturnType<typeof theExport>>.
  • kind: 'type' — the export IS the shape, a type alias or interface.

Codegen throws an actionable error if module doesn't resolve to a file, or export doesn't exist in it (or doesn't look like the declared kind) — a broken shared.ts import would otherwise surface far from its cause, as a confusing type error in the frontend build.

Several Inertia apps (scopes)

nestjs-inertia can serve more than one Inertia app from one Nest app: the default one (InertiaModule.forRoot) and one per InertiaModule.forFeature({ scope }), each with its own root view, Vite build and shared props, and controllers pick theirs with @UseInertia(scope). Point the codegen at each app's pages with pages.scopes:

nestjs-codegen.config.ts
export default defineConfig({
  pages: {
    glob: '../web/inertia/pages/**/*.tsx', // the default app: "Dashboard", "users/Show", …
    scopes: {
      // InertiaModule.forFeature({ scope: 'admin' }): "admin/Home", "admin/users/Show", …
      admin: { glob: '../admin/inertia/pages/**/*.tsx' },
    },
  },
  // …
});
  • Each scope's pages are named relative to its glob, with the prefix '<scope>/' (set prefix to change it, '' for none, and componentNameStrategy to name that scope's pages differently). That's the name its handlers render: @Inertia('admin/Home').
  • glob (for the default app and for each scope) takes one glob or an array of them.
  • All pages of every scope are in InertiaPages/InertiaPageName, so @Inertia() and render() are checked for all of them, and two pages resolving to the same name are an error. pages.d.ts also exports InertiaScopePages (scope → its page names) and InertiaScopePageName<'admin'>, for typing one app's page resolver.
  • components.json records each page's scope.

Shared props are per scope too: the share of a forFeature scope is the only one its pages get. To type them, pass scopes to nestjsInertiaCodegen() (extension 0.4+), with the same { module, export, kind } as shared:

nestjs-codegen.config.ts
nestjsInertiaCodegen({
  shared: { module: 'src/inertia/shared-props', export: 'buildSharedProps', kind: 'function' },
  scopes: {
    admin: {
      shared: { module: 'src/admin/admin-shared-props', export: 'buildAdminSharedProps', kind: 'function' },
    },
  },
}),

shared.ts then also exports InertiaScopeSharedProps ({ admin: … }) and ScopeSharedProps<'admin'>, so the admin app reads usePage<ScopeSharedProps<'admin'>>().

Excluding pages from the API prefix

If you call setGlobalPrefix('api', { exclude }), a hand-maintained exclude list is easy to let drift out of sync with your actual Inertia pages. Pass pageExcludes: true to generate it instead:

nestjs-codegen.config.ts
extensions: [nestjsInertiaCodegen({ pageExcludes: true })],

This scans contracts.glob for @Inertia-decorated, HTTP-method-decorated controller methods and emits a framework-free page-excludes.ts:

src/generated/page-excludes.ts
export const inertiaPageExcludes = [
  { path: '/users', method: 'GET' },
  { path: '/users/:id', method: 'GET' },
] as const;
src/main.ts
import { inertiaPageExcludes } from './generated/page-excludes';

app.setGlobalPrefix('api', { exclude: inertiaPageExcludes });

Throws if zero @Inertia pages are found under contracts.glob — an empty list almost always means the glob doesn't reach your view controllers.

Both shared and pageExcludes are additive and independently opt-in. Calling nestjsInertiaCodegen() with no arguments is unchanged — no extra files, only the navigate() header contribution described above.

On this page