express-vite-plugin
Zero-config Vite SSR integration plugin for Express and NestJS applications.
5
| Install | |
|---|---|
npm install express-vite-plugin |
|
| Latest Version: | 1.0.2 |
| License: | MIT |
| Last Updated: | Sep 18, 2026 |
| Links: | npm |
Maintainer: 810ritiksaini
express-vite-plugin ๐
A lightweight, zero-dependency, template-engine agnostic plugin for Express.js and NestJS applications that integrates modern frontend tooling with Vite (Vue, React, Svelte, or Vanilla JS) and Server-Side Rendering (SSR) templates (EJS, Handlebars, Pug, Eta, etc.).
Features
- ๐ชถ Zero Runtime Dependencies: No hard dependencies on
@nestjs/*,rxjs, or any template engine. - ๐จ View-Engine Agnostic: Works natively with Handlebars (HBS), EJS, Pug, Eta, or any custom template engine.
- โก One-Liner Setup:
useExpressVite(app)for Express oruseNestVite(app)for NestJS. - ๐งฉ Express & NestJS Native: Works out-of-the-box on both pure Express.js and NestJS applications.
- ๐ฆ Dual Mode (Dev & Prod):
- Development: Seamless Hot Module Replacement (HMR) powered by the live Vite dev server.
- Production: Automatically resolves chunked, hashed JS and CSS assets from
.vite/manifest.json.
- ๐ Inertia-style Dynamic Component Routing: Return
{ _component: 'MyComponent', ...props }from your controllers/routes to dynamically mount Vue/React components into your master layout without writing boilerplate view templates. - ๐ฏ Dynamic Client Entry Point: Set default entry via
entryoption, override per route via{ _entry: 'src/admin/main.ts' }, or pass directly to helper{{{ vite "src/admin.ts" }}}/<%- vite("src/admin.ts") %>. - ๐ Server-to-Client Prop Injection: Safely serializes server data and delivers it to frontend components as reactive props.
- ๐ ๏ธ Universal Template Helpers:
vite()andjson(this)helpers available onres.localsacross all view engines.
Installation
npm install express-vite-plugin
Install your preferred template engine (optional):
# For Handlebars
npm install hbs
# Or for EJS
npm install ejs
# Or for Pug
npm install pug
Quickstart
1. In Express.js (Handlebars or EJS)
import express from 'express';
import { useExpressVite } from 'express-vite-plugin';
const app = express();
// ๐ Enable Vite SSR integration in one line (defaults to 'hbs')
useExpressVite(app, {
entry: 'src/resource/main.ts', // default client entry point
viewEngine: 'hbs', // 'hbs' | 'ejs' | 'pug' | false (default: 'hbs')
viewsDir: 'views', // default: process.cwd() + '/views'
layout: 'index', // default: 'index'
publicDir: 'public', // default: process.cwd() + '/public'
});
// Dynamic Component Route (Auto-mounts <Dashboard /> into views/index.hbs or views/index.ejs)
app.get('/dashboard', (req, res) => {
res.renderComponent('Dashboard', {
user: { name: 'Ritik', role: 'Admin' },
});
// Or simply: res.json({ _component: 'Dashboard', user: { name: 'Ritik' } });
});
// Dynamic Component with custom client entrypoint
app.get('/admin', (req, res) => {
res.renderComponent('AdminPanel', { role: 'superadmin' }, 'src/admin/main.ts');
// Or: res.json({ _component: 'AdminPanel', _entry: 'src/admin/main.ts' });
});
// Regular API route (Returns pure JSON)
app.get('/api/users', (req, res) => {
res.json({ users: ['Alice', 'Bob'] });
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
Alternatively, as standard Express Middleware:
import express from 'express';
import { expressVitePlugin } from 'express-vite-plugin';
const app = express();
app.set('view engine', 'ejs'); // or 'hbs', 'pug'
app.use(expressVitePlugin({ entry: 'src/resource/main.ts' }));
2. In NestJS
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { useNestVite } from 'express-vite-plugin';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// ๐ Enable Vite SSR integration in one line
useNestVite(app, {
entry: 'src/resource/main.ts',
viewEngine: 'hbs', // or 'ejs'
});
await app.listen(3000);
}
bootstrap();
Controller Usage in NestJS
import { Controller, Get, Render } from '@nestjs/common';
@Controller()
export class AppController {
// 1. Dynamic Component: Automatically mounts <Dashboard /> using default entrypoint
@Get('dashboard')
getDashboard() {
return {
_component: 'Dashboard',
user: { name: 'Ritik' },
};
}
// 2. Dynamic Component with Custom Client Entrypoint
@Get('admin')
getAdmin() {
return {
_component: 'AdminPanel',
_entry: 'src/admin/main.ts',
role: 'superadmin',
};
}
// 3. Specific View: Renders views/about.hbs (or about.ejs) wrapped in index template
@Get('about')
@Render('about')
getAbout() {
return { title: 'About Us' };
}
// 4. API Response: Returns pure JSON when _component is omitted or requested by API clients
@Get('api/data')
getData() {
return { status: 'success', timestamp: Date.now() };
}
}
Master Layout Templates
Option A: Handlebars (views/index.hbs)
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{#if title}}{{title}}{{else}}My App{{/if}}</title>
<!-- Server data serialized for client-side Vue/React -->
<script id="__INITIAL_DATA__" type="application/json">
{{{ json this }}}
</script>
<!-- Injects dynamic Vite script (uses default entry, route _entry, or custom {{{ vite "src/path.ts" }}}) -->
{{{ vite }}}
</head>
<body>
<div id="app">
{{#if _component}}
<component :is="'{{_component}}'" v-bind="$data"></component>
{{else}}
{{{ body }}}
{{/if}}
</div>
</body>
</html>
Option B: EJS (views/index.ejs)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= typeof title !== 'undefined' ? title : 'My App' %></title>
<!-- Server data serialized for client-side Vue/React -->
<script id="__INITIAL_DATA__" type="application/json">
<%- json(locals) %>
</script>
<!-- Injects dynamic Vite script (uses default entry, route _entry, or custom <%- vite("src/path.ts") %>) -->
<%- vite() %>
</head>
<body>
<div id="app">
<% if (typeof _component !== 'undefined' && _component) { %>
<component :is="'<%= _component %>'" v-bind="$data"></component>
<% } else if (typeof body !== 'undefined') { %>
<%- body %>
<% } %>
</div>
</body>
</html>
โก Client SPA Routing (Instant Component Swapping)
express-vite-plugin enables Inertia-style Single Page Navigation with zero page reloads:
- First Load / Hard Refresh: The server renders the full HTML master layout (
views/index.hbs) and bootstraps the Vue app with{{{ json this }}}. - Subsequent Link Clicks: The client router intercepts clicks and requests
Accept: application/json. The server returns pure JSON{ _component: 'Dashboard', ...props }, and Vue smoothly swaps components in-place without refreshing the browser!
sequenceDiagram
autonumber
actor User as User
participant App as Vue Client (App.vue)
participant Router as SPA Router (router.ts)
participant Server as Express / NestJS Server
User->>App: Clicks link (e.g. "/dashboard")
App->>Router: Intercepts click event
Router->>Server: fetch("/dashboard", { Accept: "application/json" })
Server-->>Router: Returns JSON { _component: "Dashboard", user: {...} }
Router->>App: Updates reactive props & swaps component
App->>User: Renders <Dashboard /> with smooth fade/slide transition
Router->>Router: window.history.pushState() (updates URL & history)
1. Client Router (src/resource/router.ts)
import { ref, computed, type Component } from 'vue';
export const currentProps = ref<Record<string, any>>({});
export const registeredComponents: Record<string, Component> = {};
export const isLoading = ref(false);
export const resolvedComponent = computed(() => {
const name = currentProps.value._component;
if (!name) return null;
return registeredComponents[name] || registeredComponents[name.toLowerCase()] || null;
});
export function setupRouter(components: Record<string, Component>, initialData: Record<string, any>) {
Object.assign(registeredComponents, components);
currentProps.value = initialData;
// Handle browser Back / Forward buttons
window.addEventListener('popstate', async (event) => {
if (event.state?._component) {
currentProps.value = event.state;
if (event.state.title) document.title = event.state.title;
} else {
await visit(window.location.pathname, false);
}
});
// Intercept all internal <a> link clicks for instant SPA transitions
document.addEventListener('click', (event) => {
const target = (event.target as HTMLElement).closest('a');
if (!target) return;
const href = target.getAttribute('href');
const isExternal = target.target === '_blank' || target.hasAttribute('download');
const hasModifier = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
if (href && href.startsWith('/') && !href.startsWith('//') && !isExternal && !hasModifier) {
event.preventDefault();
visit(href);
}
});
}
export async function visit(url: string, pushState = true) {
isLoading.value = true;
try {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await response.json();
if (data?._component) {
currentProps.value = data;
if (pushState) window.history.pushState(data, '', url);
if (data.title) document.title = data.title;
return;
}
}
// Fallback for traditional non-component SSR pages (e.g. /about)
window.location.href = url;
} catch (error) {
window.location.href = url;
} finally {
isLoading.value = false;
}
}
2. Root Component (src/resource/App.vue)
<template>
<div class="app-layout">
<!-- Top Progress Loading Bar -->
<div v-if="isLoading" class="spa-loader"></div>
<!-- Smooth Component Transition -->
<transition name="page" mode="out-in">
<component
:is="resolvedComponent"
:key="currentProps._component || 'view'"
v-bind="currentProps"
/>
</transition>
</div>
</template>
<script setup lang="ts">
import { currentProps, resolvedComponent, isLoading } from './router';
</script>
3. Client Entry Point (src/resource/main.ts)
import { createApp, type Component } from 'vue';
import './style.css';
import App from './App.vue';
import { setupRouter } from './router';
// Read server-rendered JSON initial props
const dataElement = document.getElementById('__INITIAL_DATA__');
let initialProps: Record<string, any> = {};
if (dataElement?.textContent) {
try {
initialProps = JSON.parse(dataElement.textContent);
} catch (error) {
console.error('Failed to parse initial server data:', error);
}
}
// Auto-discover all Vue components in ./components
const componentModules = import.meta.glob<{ default: Component }>('./components/**/*.vue', {
eager: true,
});
const components: Record<string, Component> = {};
for (const [path, module] of Object.entries(componentModules)) {
const component = module?.default;
if (!component) continue;
const match = path.match(/\/([^/]+)\.vue$/);
if (match && match[1]) {
components[match[1]] = component;
components[match[1].toLowerCase()] = component;
}
}
// Initialize SPA router
setupRouter(components, initialProps);
const mountEl = document.getElementById('app');
if (mountEl && initialProps._component) {
const app = createApp(App);
for (const [name, comp] of Object.entries(components)) {
app.component(name, comp);
}
app.mount(mountEl);
}
Vite Configuration (vite.config.ts)
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
build: {
manifest: true,
outDir: 'dist/public',
rollupOptions: {
input: resolve(__dirname, 'src/resource/main.ts'),
},
},
server: {
strictPort: true,
port: 5173,
},
});
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
entry |
string |
'src/resource/main.ts' |
Default client entry file path. Can be overridden dynamically per route. |
viewEngine |
string | false |
'hbs' |
Template engine ('hbs', 'ejs', 'pug', 'eta'). Set to false if managed manually. |
viewsDir |
string | string[] |
process.cwd() + '/views' |
Directory or array of directories for templates. |
layout |
string | false |
'index' |
Default layout template name. Set to false to disable. |
publicDir |
string |
process.cwd() + '/public' |
Static assets directory. |
vitePort |
number |
5173 |
Port where Vite dev server runs. |
viteHost |
string |
'localhost' |
Host where Vite dev server runs. |
manifestPath |
string |
Auto-detected | Custom path to .vite/manifest.json. |
isProd |
boolean |
NODE_ENV === 'production' |
Override production mode. |
hbs |
any |
require('hbs') |
Custom Handlebars instance (if using HBS). |
License
MIT ยฉ Ritik
Related Packages
Version History
| Version | Released | License |
|---|---|---|
| 1.0.2 | MIT |