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.).

npm version License: MIT Zero Dependencies View Engines


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 or useNestVite(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 entry option, 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() and json(this) helpers available on res.locals across 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

vite-ssr

Vite utility for server side rendering

64,502 833
@mcn-info/vite-ssr

Vite utility for server side rendering

2,023
languages-js

Translation system in Javascript with the JSON format.

11,423 6
@meadmin/midway-vite-view

midway ็š„ vite ๆœๅŠก็ซฏๆธฒๆŸ“ๅ’Œๅฎขๆˆท็ซฏๆธฒๆŸ“็ป„ไปถ๏ผŒ

3,844 10
vue-universal-cookies

Cookies plugin for Browser, Express, http on Vue.js

2,714 30

Version History

Version Released License
1.0.2 MIT