I switched my Vue front-end apps from webpack to Vite. I’d been on webpack 5 for years — vue-loader, HtmlWebpackPlugin, MiniCssExtractPlugin, CopyPlugin, and Terser — and the config was always the most tedious file in the project. Vite replaced all of that with one small file. This post is the setup I landed on.
It’s pronounced “veet” — like “peace,” not “vite” like “kite.” Though if you hear me say it out loud, there’s a decent chance I’ll get it wrong anyway.
My apps are Vue 3 SPAs with Tailwind CSS and PostCSS, built with shell scripts and run entirely in Docker. Three file moves set the shape of the project:
html/index.htmlgoes to the project root with a<script type="module">tag addedstatic/becomespublic/(Vite serves this directory at root automatically)- The manual
vendor.jsentry file goes away (Vite splits vendor code viamanualChunks)
Vite handles CSS extraction, HTML generation, and asset copying without plugins; SFC compilation needs only @vitejs/plugin-vue. The config is mostly about the few things it doesn’t default correctly for this stack.
vite.config.js
Here’s the complete config. One file, and it replaces the whole webpack config plus its plugin chain:
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
var __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig(function ({ command }) {
var isDev = command === 'serve';
return {
plugins: [vue()],
css: {
postcss: resolve(__dirname, 'postcss.config.cjs'),
},
server: {
port: 3001,
host: '0.0.0.0',
watch: process.env.DOCKER
? { usePolling: true }
: {},
hmr: process.env.SITE_ADDRESS
? {
protocol: 'wss',
host: process.env.SITE_ADDRESS,
port: 8443,
}
: {},
},
build: {
outDir: resolve(__dirname, 'dist'),
emptyOutDir: true,
rollupOptions: {
// Split third-party code into its own chunk so it caches long-term
// across app-code changes; users only re-download the small app
// chunk on deploys where the dependencies are unchanged.
//
// Caveat: lumping all of node_modules into one chunk works for a
// simple SPA but can produce broken chunks in apps with circular
// dependencies between deps or with dynamic imports. For those,
// split by top-level package instead.
output: {
manualChunks: function (id) {
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
},
// 'vue' resolves to the runtime-only build by default
// (vue.runtime.esm-bundler.js). Templates come from precompiled SFCs, so
// the template compiler isn't needed at runtime. This keeps the bundle
// smaller and avoids eval-style dynamic code generation, which keeps
// strict CSP rules happy.
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: isDev,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: isDev,
},
};
});
A few things worth pointing out:
@vitejs/plugin-vueis the only plugin. It handles SFC compilation.- There’s no
vuealias.vueresolves to the runtime-only build (vue.runtime.esm-bundler.js) by default, which is what you want when every template is a precompiled SFC. The bundle is smaller, and because there’s no runtime template compiler there’s noeval-style dynamic code generation — which keeps strict CSP rules happy. You’d only reach for thevue/dist/vue.esm-bundler.jsalias if you needed to compile template strings at runtime. definesets the Vue 3 feature flags. The two dev-tools flags trackisDev(on in dev, off in production);__VUE_OPTIONS_API__stays on. Without these Vue ships more code than it needs to and can log dev warnings in production.css.postcsspoints explicitly at the PostCSS config so there’s no ambiguity about which file Vite picks up.server.host: '0.0.0.0'so the dev server is reachable from outside the container. Without it, Docker port forwarding doesn’t reach Vite.server.watchpolls only whenDOCKERis set. inotify doesn’t cross the Docker volume mount boundary, so polling is needed inside the container but wasteful on the host.server.hmris conditional onSITE_ADDRESS. Locally the block is empty and Vite’s defaults take over. Behind the proxy the browser connects towss://$SITE_ADDRESS:8443. More on this below.manualChunksis a function that sends anything innode_modulesto avendorchunk, so third-party code caches long-term across app-code changes.public/is served at root automatically — no config line at all.
If you have more than one app in the project (a separate signin page, for example), add rollupOptions.input with an object mapping each entry name to its HTML file. The single-entry form above is the common case.
HMR through a reverse proxy
This is the part that never worked for me on webpack. I had HMR disabled (hot: false) because I couldn’t get the WebSocket through my Caddy reverse proxy. With Vite it works.
When SITE_ADDRESS is set, the dev server tells the browser to connect to the HMR WebSocket over wss://$SITE_ADDRESS:8443. Caddy terminates TLS on 8443 and proxies the WebSocket through to the Vite dev server on the same route as the rest of the dev server — no special /ws handler like webpack needed. Without SITE_ADDRESS the hmr block is empty and Vite’s defaults take over, which is what I want locally where there’s no proxy.
Production build
Vite outputs ESM modules (<script type="module">) instead of IIFE scripts. Modern browsers handle this fine. The vendor/app split is there via manualChunks. Content hashing is automatic. esbuild replaces Terser as the minifier, which is much faster. Source maps are off by default; add build.sourcemap: true if you need them.
On the app I switched first, webpack produced 334 KB of JS+CSS on disk and Vite produces 330 KB. Both gzip to about 97 KB over the wire. Comparable output.
PostCSS
Vite auto-loads PostCSS config, but I point at it explicitly with css.postcss so there’s no ambiguity about which file is picked up. The config is CommonJS and uses explicit requires, not string shorthand names:
// postcss.config.cjs
const tailwindcss = require('tailwindcss');
const autoprefixer = require('autoprefixer');
const postcssPresetEnv = require('postcss-preset-env');
module.exports = {
plugins: [tailwindcss, postcssPresetEnv, autoprefixer],
};
String shorthand ('tailwindcss') causes an “Invalid PostCSS Plugin” error under Vite. Pass actual plugin objects.
The Tailwind content paths needed updating since html/ no longer exists — './html/**/*.html' becomes './index.html'.
package.json
Fourteen webpack-related dependencies come out. Two come in: vite and @vitejs/plugin-vue. The build script is npx vite build and the dev server is npx vite. vite.config.js is written in ESM syntax, but Vite loads the config itself, so the project doesn’t need "type": "module".
What tripped me up
The PostCSS string shorthand was the first thing that broke — Vite wants actual plugin objects, not names. Forgetting the Vue feature flags in define doesn’t break anything visibly, it just ships extra code and can log dev warnings in production, so set them up front.
No component changes. No router changes. No changes to the app code at all. It’s purely a build tool change.
Why it’s worth it
The dev server starts instantly instead of waiting on a webpack compile. HMR works through my proxy. The config is much smaller. The dependency list dropped by more than half. I’m migrating my other apps the same way.