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 Inertiarouter.
pnpm add -D @dudousxd/nestjs-inertia-codegen-extensionimport { 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 + paramsWhen 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:
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. theshare()callback); the type isAwaited<ReturnType<typeof theExport>>.kind: 'type'— the export IS the shape, atypealias orinterface.
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:
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>/'(setprefixto change it,''for none, andcomponentNameStrategyto 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()andrender()are checked for all of them, and two pages resolving to the same name are an error.pages.d.tsalso exportsInertiaScopePages(scope → its page names) andInertiaScopePageName<'admin'>, for typing one app's page resolver. components.jsonrecords each page'sscope.
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:
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:
extensions: [nestjsInertiaCodegen({ pageExcludes: true })],This scans contracts.glob for @Inertia-decorated, HTTP-method-decorated controller
methods and emits a framework-free page-excludes.ts:
export const inertiaPageExcludes = [
{ path: '/users', method: 'GET' },
{ path: '/users/:id', method: 'GET' },
] as const;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.