Vite Build
Gentelella's Vite 8 config does three smart things — auto-discovers every page in production/ as an entry, splits vendor bundles for ECharts/DataTables/Leaflet, and injects the sidebar/topbar/footer at build time via a custom plugin. Here's how it's set up.
Last updated May 22, 2026
Gentelella v4 is a Vite 8 multi-page app. No webpack, no Parcel, no PostCSS chain. Vite handles SCSS compilation directly through its bundled Sass support. The config lives in vite.config.js — about 200 lines total, all of which earn their place.
The four interesting things
- Auto-discovered entries — drop an HTML file in
production/, no config edit - Shell injection plugin — sidebar/topbar/footer rendered at build time, no FOUC
- Lazy vendor chunking — ECharts, DataTables, Leaflet split into separate chunks loaded only by pages that need them
- Bundle analyzer baked in via
rollup-plugin-visualizer
Entry discovery
Most multi-page Vite setups hand-maintain a list of HTML entries in rollupOptions.input. Gentelella reads production/ at config time:
function discoverEntries() {
const dir = resolve(import.meta.dirname, 'production');
const out = {};
for (const file of readdirSync(dir)) {
if (!file.endsWith('.html')) continue;
// index.html → chunk name 'main' (kept for backwards compat);
// everything else uses the filename stem.
const stem = file === 'index.html' ? 'main' : file.replace(/\.html$/, '');
out[stem] = `production/${file}`;
}
return out;
}
// ...later in defineConfig:
rollupOptions: {
input: discoverEntries(),
// ...
}
Add a new page in production/, restart npm run dev, Vite picks it up. No file to edit, no list to maintain. See Adding a page for the full page-add recipe.
Vendor chunking
Without configuration, Vite would bundle all third-party JS into a single vendors-[hash].js chunk — which means every page downloads ECharts, even pages that don’t render any charts. Gentelella overrides manualChunks to split by library:
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (/node_modules\/echarts\//.test(id)) return 'vendor-echarts';
if (/node_modules\/datatables\.net\//.test(id)) return 'vendor-tables';
if (/node_modules\/leaflet\//.test(id)) return 'vendor-maps';
}
Three named chunks: vendor-echarts.js (~400 KB), vendor-tables.js (~60 KB), vendor-maps.js (~150 KB). Pages dynamic-import the modules that reference them — main-v4.js calls import('echarts/core') only when it sees a [data-chart] element on the page, and Vite’s chunking ensures only vendor-echarts.js gets fetched.
Asset filename hashing
output: {
assetFileNames: (assetInfo) => {
const name = assetInfo.name ?? assetInfo.names?.[0] ?? '';
if (/\.(png|jpe?g|svg|gif|tiff|bmp|ico)$/i.test(name)) {
return `images/[name]-[hash][extname]`;
}
if (/\.(woff2?|eot|ttf|otf)$/i.test(name)) {
return `fonts/[name]-[hash][extname]`;
}
return `assets/[name]-[hash][extname]`;
},
chunkFileNames: 'js/[name]-[hash].js',
entryFileNames: 'js/[name]-[hash].js'
}
Output layout in dist/:
dist/
├── images/ *-[hash].png
├── fonts/ *-[hash].woff2
├── assets/ *-[hash].css (and other non-image, non-font assets)
├── js/ *-[hash].js
└── production/
├── index.html
├── inbox.html
└── ...
Every asset URL is content-hashed, so you can set a far-future Cache-Control: immutable header on /images/, /fonts/, /assets/, and /js/ — Vite invalidates by writing new hashes on the next build.
HTML stays at the root of each entry. Cache headers for HTML should be no-cache so users always get the freshest references.
The shell injection plugin
This is the most distinctive piece. A custom Vite plugin in vite.config.js hooks transformIndexHtml and injects three things into every page:
- PWA meta tags — manifest link, theme color, mobile-web-app-capable
- SEO + OG meta — description (derived from breadcrumb), Open Graph title/image, Twitter card
- Pre-paint theme script — reads localStorage, sets
data-themebefore CSS arrives - Sidebar/topbar/footer DOM — only for pages with
<body data-shell="admin">
The shell DOM is rendered by renderShell() from src/v4/shell-render.js — a pure function that returns HTML strings. No DOM, no window, no document. That’s what makes it usable from the Vite plugin at build time as well as from mountShell() at runtime as a fallback.
import { renderShell, parseShellAttrs } from './src/v4/shell-render.js';
function shellInjectionPlugin() {
return {
name: 'gentelella-shell-injection',
transformIndexHtml: {
order: 'pre',
handler(html) {
// ...inject PWA/SEO/pre-paint into <head>...
const bodyTag = /<body\b([^>]*)>/i.exec(out);
const parsed = parseShellAttrs(bodyTag[1]);
if (!parsed) return out;
const { sidebar, topbar, footer } = renderShell(parsed);
out = out.replace(
/<main\s+class=["']main["']/i,
`<a class="skip-link" href="#main-content">Skip to main content</a>\n${sidebar}\n${topbar}\n<main id="main-content" tabindex="-1" class="main"`
);
out = out.replace(/<\/main>/i, `${footer}\n</main>`);
return out;
}
}
};
}
The benefit: every HTML file you ship already contains the complete shell DOM. No FOUC, no client-side JS needed to render the chrome, search-engine crawlers see the navigation.
mountShell() at runtime detects the pre-rendered shell and skips re-rendering — but it still wires event handlers (drawer toggle, theme toggle, dropdowns) either way.
Server config (dev)
server: {
open: '/production/index.html',
port: Number(process.env.PORT) || 9173,
host: true,
proxy: {
'/api': {
target: process.env.API_URL || 'http://localhost:8080',
changeOrigin: true
}
}
}
Three things to note:
- Port 9173 is the default — uncommon enough to not collide with the dozen tools that fight over 3000 / 4000 / 5173 / 8000. Override with
PORT=4000 npm run dev. open: '/production/index.html'— auto-opens the dashboard onnpm run dev. The browser opens to the page, not the project root./api/*proxy — falls through 404 if no backend is running. The optionalexamples/express-sqlitebackend listens on:8080; with it up, the inbox / kanban can be wired to real data viadata-adapter.js.
Build optimization
Terser is configured with three passes and aggressive options:
build: {
target: 'es2022',
sourcemap: process.env.NODE_ENV === 'production' ? 'hidden' : true,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // strip console.log in production
drop_debugger: true,
passes: 3, // three-pass minification
pure_getters: true,
reduce_vars: true,
collapse_vars: true,
dead_code: true,
unused: true
},
mangle: { safari10: true },
format: { comments: false }
}
}
sourcemap: 'hidden' is the interesting choice — generates .map files but doesn’t reference them in the bundle. You can upload them to your error tracker (Sentry, Rollbar) for production stack traces without exposing them to users browsing your site.
Subpath builds
To deploy under a subpath (e.g. https://example.com/admin/):
BASE_PATH=/admin/ npm run build
BASE_PATH=/admin/ npm run preview # verify locally
Vite rewrites every asset URL with the prefix, the shell plugin uses it when generating the manifest link and theme-color meta, and the service worker resolves its scope against the base.
The shell-injection plugin reads the resolved base from Vite’s configResolved hook:
function shellInjectionPlugin() {
let base = '/';
return {
configResolved(config) { base = config.base || '/'; },
transformIndexHtml: { /* ... uses `base` in href/src ... */ }
};
}
Bundle analyzer
rollup-plugin-visualizer is wired in by default — every build emits dist/stats.html with a treemap of bundle composition:
plugins: [
visualizer({
filename: 'dist/stats.html',
open: false,
gzipSize: true,
brotliSize: true,
template: 'treemap'
})
]
Run npm run analyze to build + auto-open the treemap. Useful when something unexpectedly bloats the bundle — a quick treemap view tells you which package grew.
What’s deliberately NOT in the config
A few things you might expect but won’t find:
- No PostCSS chain. Vite bundles Sass directly; there’s no
postcss.config.js. If you need PostCSS plugins (autoprefixer, custom transforms), add them viacss.postcss— but the default Vite build covers the common cases without a chain. - No Tailwind. This is a SCSS template. Adding Tailwind would conflict with the token system — Tailwind utilities use a different mental model than CSS custom properties.
- No Babel. Vite targets ES2022 directly. If you need to support older browsers, add
@vitejs/plugin-legacy— but the template’s stance is “modern evergreen browsers, no transpilation cost”. - No PWA plugin. Service worker and manifest are hand-written in
public/sw.jsandpublic/site.webmanifest(see PWA setup).vite-plugin-pwais fine if you want offline-first features beyond what the hand-written SW provides — Gentelella’s defaults are basic and tunable.
See also
- Adding a page — the entry-discovery side of this config
- PWA setup — the meta tags + manifest path the shell plugin injects
- Deployment — what to do with
dist/once Vite builds it