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.html goes to the project root with a <script type="module"> tag added
  • static/ becomes public/ (Vite serves this directory at root automatically)
  • The manual vendor.js entry file goes away (Vite splits vendor code via manualChunks)

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:

js
// 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-vue is the only plugin. It handles SFC compilation.
  • There’s no vue alias. vue resolves 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 no eval-style dynamic code generation — which keeps strict CSP rules happy. You’d only reach for the vue/dist/vue.esm-bundler.js alias if you needed to compile template strings at runtime.
  • define sets the Vue 3 feature flags. The two dev-tools flags track isDev (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.postcss points 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.watch polls only when DOCKER is set. inotify doesn’t cross the Docker volume mount boundary, so polling is needed inside the container but wasteful on the host.
  • server.hmr is conditional on SITE_ADDRESS. Locally the block is empty and Vite’s defaults take over. Behind the proxy the browser connects to wss://$SITE_ADDRESS:8443. More on this below.
  • manualChunks is a function that sends anything in node_modules to a vendor chunk, 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:

js
// 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.