inertianest
v1.0.1
Published
A package for Inertia.js adapters for Nestjs with (Express and Fastify)
Maintainers
Readme
Inertianest
Inertia.js adapters for Express and Fastify in NestJS applications.
Installation
npm install inertianestSetup
With Express
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
import { join } from 'path';
import { readFileSync } from 'fs';
import { Request, Response, NextFunction } from 'express';
interface ViteManifestEntry {
file: string;
src?: string;
isEntry?: boolean;
css?: string[];
imports?: string[];
}
type ViteManifest = Record<string, ViteManifestEntry>;
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Setup view engine (example with ejs)
app.useStaticAssets(join(__dirname, '..', 'public'));
app.setBaseViewsDir(join(__dirname, '..', 'views'));
app.setViewEngine('ejs');
// Load manifest.json ke locals supaya bisa dipakai di semua ejs
const manifestPath = join(
__dirname,
'..',
'public',
'.vite',
'manifest.json',
);
const manifest = JSON.parse(
readFileSync(manifestPath, 'utf-8'),
) as ViteManifest;
const entry = manifest['main.js'];
app.use((req: Request, res: Response, next: NextFunction) => {
res.locals.viteEntry = entry;
next();
});
await app.listen(3000);
}example home.module.ts
import { Module } from '@nestjs/common';
import { HomeController } from './home.controller';
import { InertiaModule } from 'inertianest';
@Module({
imports: [
InertiaModule.register({
adapter: 'express',
view: 'app', // Your base view file name
version: '1.0',
}),
],
controllers: [HomeController],
})
export class HomeModule {}With Fastify
import { NestFactory } from '@nestjs/core';
import { NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';
import { join } from 'path';
import fastifyView from '@fastify/view';
import ejs from 'ejs';
import { AppModule } from './app.module';
interface ViteManifestEntry {
file: string;
src?: string;
isEntry?: boolean;
css?: string[];
imports?: string[];
}
type ViteManifest = Record<string, ViteManifestEntry>;
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter()
);
// Register view engine
await app.register(fastifyView, {
engine: {
ejs: ejs,
},
root: join(__dirname, '..', 'views')
});
// Load manifest.json ke locals supaya bisa dipakai di semua ejs
const manifestPath = join(
__dirname,
'..',
'public',
'.vite',
'manifest.json',
);
const manifest = JSON.parse(
readFileSync(manifestPath, 'utf-8'),
) as ViteManifest;
const entry = manifest['main.js'];
app.use((req: Request, res: Response, next: NextFunction) => {
res.locals.viteEntry = entry;
next();
});
await app.listen(3000);
}Usage
In your controllers
import { Controller, Get } from '@nestjs/common';
import { Render } from 'inertianest';
@Controller('users')
export class UsersController {
@Post('create')
@Render('Users/Create')
async create() {
const user = await this.userService.create();
return {
statusCode: 201,
flash: { message: 'User created successfully' },
viewData: { title: 'Create User' },
props: { user }
};
}
}Base View Template
For Express (views/app.ejs):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<!-- Your assets here -->
<% if (process.env.NODE_ENV==='development' ) { %>
<!-- Scripts for development -->
<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/main.js"></script>
<% } else { %>
<!-- Assets for production taken from generated manifest file -->
<% if (viteEntry.css) { %>
<% viteEntry.css.forEach(function (cssFile) { %>
<link rel="stylesheet" href="/<%= cssFile %>">
<% }); %>
<% } %>
<script type="module" src="/<%= viteEntry.file %>"></script>
<% } %>
</head>
<body>
<div id="app" data-page='<%- inertiaData %>'></div>
<!-- Your app scripts here -->
</body>
</html>For Fastify (views/app.ejs):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<!-- Your assets here -->
<% if (process.env.NODE_ENV==='development' ) { %>
<!-- Scripts for development -->
<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/main.js"></script>
<% } else { %>
<!-- Assets for production taken from generated manifest file -->
<% if (viteEntry.css) { %>
<link rel="stylesheet" href="/<%= viteEntry.css[0] %>">
<% } %>
<script type="module" src="/<%= viteEntry.file %>"></script>
<% } %>
</head>
<body>
<div id="app" data-page='<%= inertiaData %>'></div>
<!-- Your app scripts here -->
</body>
</html>Flash Messages
You can use the @Flash() decorator to set flash messages:
import { Controller, Post } from '@nestjs/common';
import { Flash } from 'inertianest';
@Controller()
export class AppController {
@Post('submit')
@Flash({ message: 'Data saved successfully!' })
@Render('Result')
submit() {
return { success: true };
}
}Build
"build": "nest build && cd client && vite build",
"dev": "NODE_ENV=development concurrently \"npm:start:dev\" \"npm:start:client\" -c \"blue,green\" -k",
"start:client": "cd client && vite",Vue Depedencies
"@inertiajs/inertia",
"@inertiajs/progress",
"@inertiajs/vue3",
"concurrently",
"ejs",
// "laravel-vite-plugin",
"vue",
"unplugin-auto-import",
"unplugin-icons",
"unplugin-vue-components",
"vite",
"vite-svg-loader",
"@vitejs/plugin-vue",Vue Setup
create client/main.js
import { createApp, h } from 'vue';
import { InertiaProgress } from '@inertiajs/progress';
import { createInertiaApp } from '@inertiajs/vue3';
// import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
// Initialize progress bar
InertiaProgress.init();
// Create Inertia app
createInertiaApp({
title: (title) => `${title} - Example`,
resolve: name => {
const pages = import.meta.glob('./pages/**/*.vue', { eager: true })
return pages[`./pages/${name}.vue`]
},
// resolve: (name) => resolvePageComponent(`./pages/${name}.vue`, import.meta.glob('./pages/**/*.vue')),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
});create client/vite.config.js
import { resolve } from 'path';
import Vue from '@vitejs/plugin-vue';
import AutoImport from 'unplugin-auto-import/vite';
import Icons from 'unplugin-icons/vite';
import IconsResolver from 'unplugin-icons/resolver';
import Components from 'unplugin-vue-components/vite';
import { HeadlessUiResolver } from 'unplugin-vue-components/resolvers';
import SvgLoader from 'vite-svg-loader';
export default () => ({
publicDir: 'fake_dir_so_nothing_gets_copied',
build: {
manifest: true,
outDir: resolve(__dirname, '../public'),
rollupOptions: {
input: './main.js',
},
},
resolve: {
alias: {
'@': resolve(__dirname, '.'),
},
},
plugins: [
Vue({}),
AutoImport({
imports: [
'vue',
{
'@inertiajs/inertia': ['Inertia'],
'@inertiajs/vue3': ['usePage', 'useForm'],
},
],
dts: false,
}),
// https://github.com/antfu/unplugin-vue-components
Components({
dirs: ['components', 'layouts'],
dts: false,
resolvers: [
IconsResolver({
componentPrefix: '',
}),
HeadlessUiResolver(),
(name) => {
if (['Head', 'Link'].includes(name)) {
return {
from: '@inertiajs/vue3',
name: name,
};
}
},
],
}),
Icons(),
SvgLoader(),
],
});create client/layouts/Main.vue
<script>
</script>
<template>
<main>
<slot />
</main>
</template>create client/pages/Home.vue
<script setup>
import Main from '@/layouts/Main.vue';
defineProps({
title: String,
description: String,
});
</script>
<template>
<Main>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
</Main>
</template>Shared Props
create inertia.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
import { InertiaExpress } from 'inertianest';
@Injectable()
export class InertiaMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
if (!res.inertia) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
res.inertia = new InertiaExpress(req as any, res as any);
}
res.inertia.share({
content: 'contoh content',
});
next();
}
}modify home.module.ts
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { HomeController } from './home.controller';
import { InertiaModule } from 'inertianest';
import { InertiaMiddleware } from 'src/inertia/inertia.middleware';
@Module({
imports: [
InertiaModule.register({
adapter: 'express',
view: 'app', // Your base view file name
version: '1.0',
}),
],
controllers: [HomeController],
})
export class HomeModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(InertiaMiddleware).forRoutes('home');
}
}modify client/pages/Home.vue
<script setup>
import Main from '@/layouts/Main.vue';
import { usePage } from '@inertiajs/vue3';
defineProps({
title: String,
description: String,
});
const page = usePage();
const content = computed(() => page.props.content);
</script>
<template>
<Main>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
<p>{{ content }}</p>
</Main>
</template>Add TailwindCSS
install
npm install tailwindcss @tailwindcss/vitemodify vite.config.js
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default () => ({
...
plugins: [
tailwindcss(),
],
})create client/style.css
@import "tailwindcss";add to client/main.js
import './style.css';Without Decorator
The better approach is to use the @Render() decorator and let NestJS handle the response lifecycle, which ensures all these features work as expected:
example express
import express from 'express';
import { inertia } from 'inertianest/express';
const app = express();
// Setup inertia middleware
app.use(inertia({
view: 'app',
version: '1.0',
manifest: {}
}));
// Setelah middleware terpasang, res.inertia tersedia di semua route
app.get('/', (req, res) => {
res.inertia.render('Home', { message: 'Hello' });
});example fastify
import fastify from 'fastify'
import { inertia } from 'inertianest/fastify'
const app = fastify()
// Register inertia middleware
app.addHook('onRequest', inertia({
view: 'app',
version: '1.0',
manifest: {}
}))
// Sekarang reply.inertia tersedia di semua route
app.get('/', async (request, reply) => {
return reply.inertia.render('Home', { message: 'Hello' })
})Lazy and Defer
Lazy Props:
- Only load when explicitly requested via partial reloads
- Will be excluded from initial page load
- Good for data that isn't immediately needed
- Must be manually triggered to load via Inertia.reload()
Deferred Props:
- Automatically load after initial page render
- Initially returns null on first render
- Then automatically fetches data without manual intervention
- Good for non-critical data that can load after page is visible
@Controller('dashboard')
export class DashboardController {
@Get()
@Render('Dashboard/Index')
async index() {
return {
props: {
// Critical data - loads immediately
user: await this.getUser(),
// Deferred - loads automatically after page render
recentActivity: Inertia.deferred(async () => {
return await this.getRecentActivity();
}),
// Lazy - only loads when manually requested
analytics: Inertia.lazy(async () => {
return await this.getAnalytics();
})
}
};
}
}And then in your Vue component, you can request partial reloads like this:
<script setup>
import { onMounted } from 'vue'
import { Inertia } from '@inertiajs/inertia'
// Deferred props load automatically - no manual trigger needed
// Will initially be null, then populate automatically
// Lazy props need manual triggering
const loadAnalytics = () => {
Inertia.reload({ only: ['analytics'] })
}
</script>
<template>
<div>
<!-- Always available immediately -->
<h1>Welcome {{ user.name }}</h1>
<!-- Initially null, loads automatically -->
<div v-if="recentActivity">
<h2>Recent Activity</h2>
<ActivityList :items="recentActivity" />
</div>
<div v-else>
Loading activity...
</div>
<!-- Only loads when button clicked -->
<button @click="loadAnalytics" v-if="!analytics">
Load Analytics
</button>
<AnalyticsChart v-else :data="analytics" />
</div>
</template>Merge
Now here's an example of how to use both deferred props and the new static merge functionality in a controller:
@Controller('posts')
export class PostsController {
@Get()
@Render('Posts/Index')
async index() {
return {
props: {
// Regular props load immediately
posts: await this.getPosts(),
// Deferred props load only when requested
comments: Inertia.deferred(async () => {
return await this.getComments();
}),
// Use static merge to combine data
users: Inertia.merge('users', [
{ id: 1, name: 'New User' }
])
}
};
}
private async getPosts() {
return [
{ id: 1, title: 'First Post' },
{ id: 2, title: 'Second Post' }
];
}
private async getComments() {
// Simulate slow API call
await new Promise(resolve => setTimeout(resolve, 2000));
return [
{ id: 1, text: 'Great post!' },
{ id: 2, text: 'Thanks for sharing' }
];
}
}And here's how to use it in your Vue component:
<script setup lang="ts">
import { Inertia } from '@inertiajs/inertia'
// Load deferred comments when needed
const loadComments = () => {
Inertia.reload({ only: ['comments'] })
}
</script>
<template>
<div>
<!-- Posts load immediately -->
<div v-for="post in posts" :key="post.id">
{{ post.title }}
</div>
<!-- Comments are null until loaded -->
<button @click="loadComments" v-if="!comments">
Load Comments
</button>
<div v-else>
<div v-for="comment in comments" :key="comment.id">
{{ comment.text }}
</div>
</div>
<!-- Users array will be merged with existing data -->
<div v-for="user in users" :key="user.id">
{{ user.name }}
</div>
</div>
</template>Development
npm run buildadd this in package.json
"inertianest": "file:../inertianest",Security
If you've found a bug regarding security, please mail [email protected] instead of using the issue tracker.
License
The MIT License (MIT). Please see License File for more information.
