Migrating from Webpack 5 to Vite

You have a working Webpack 5 application and want Vite’s native-ESM dev server and Rollup production build without a multi-week rewrite. This guide maps each Webpack concept to its Vite equivalent and lists the breakages to expect; because most ports lean on a custom plugin or two, keep Advanced Vite Plugin Configuration open for the loader-to-plugin cases. The mental shift is that Vite serves source over native ESM in dev (no bundling) and only bundles for production, so anything that assumed a CommonJS webpack runtime needs attention.

Webpack 5 to Vite concept mapping A two-column comparison mapping Webpack loaders, DefinePlugin, process.env, and HtmlWebpackPlugin to Vite plugins, define, import.meta.env, and index.html. Webpack 5 Vite loaders (babel, file, css) built-in transforms + plugins DefinePlugin define: { ... } process.env.X import.meta.env.VITE_X HtmlWebpackPlugin index.html as entry devServer.proxy server.proxy
Figure: the core Webpack 5 to Vite mapping — loaders to transforms/plugins, `DefinePlugin` to `define`, `process.env` to `import.meta.env`, `HtmlWebpackPlugin` to `index.html`.

Prerequisites & Reproducible Setup

# Vite 5.x / 6.x, Node 20+
npm install -D vite @vitejs/plugin-react
# Keep the old build working until parity is reached:
#   npm run build:webpack  ->  webpack --mode production
#   npm run dev            ->  vite

Add Vite alongside Webpack rather than ripping Webpack out first. Run both until the Vite build reaches parity, then delete webpack.config.js and the Webpack dependencies in one commit.

Step 1: index.html Becomes the Entry Point

Webpack starts from a JS entry and injects script tags via HtmlWebpackPlugin. Vite inverts this: index.html lives at the project root and is the entry, referencing your source with a normal <script type="module">.

<!-- index.html at project root — Vite 5.x / 6.x -->
<!doctype html>
<html lang="en">
  <head><meta charset="UTF-8" /><title>App</title></head>
  <body>
    <div id="root"></div>
    <!-- replaces HtmlWebpackPlugin's injected bundle tag -->
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Move any template HTML from HtmlWebpackPlugin here. Webpack’s %PUBLIC_URL% and EJS interpolation are gone; Vite uses %VITE_FOO% env replacement and /-rooted public/ assets instead.

Step 2: Loaders Become Built-in Transforms or Plugins

Most loaders disappear because Vite handles them natively. The rest map to a plugin.

Webpack loader/rule Vite equivalent
babel-loader / ts-loader (JS/TS/JSX) Built-in esbuild transform (add @vitejs/plugin-react for React Fast Refresh)
css-loader + style-loader Built-in .css import
sass-loader Built-in once sass is installed
file-loader / url-loader Built-in asset handling (import url from './x.png')
raw-loader ?raw import suffix
svgr vite-plugin-svgr
custom loader A custom plugin — see the asset-transform guide

For a bespoke loader, port it to a plugin transform hook as described in Writing a Custom Vite Plugin for Asset Transformation, and mind the enforce lane so it runs at the right time (see Debugging Vite Plugin Hook Order with enforce and apply).

Step 3: require to ESM

Vite serves native ESM, so top-level require() and module.exports break with require is not defined. Convert source to import/export. CommonJS dependencies in node_modules are fine — Vite’s esbuild pre-bundling converts them — but your own source must be ESM. require.context() has no direct equivalent; replace it with import.meta.glob:

// Webpack: const ctx = require.context('./pages', true, /\.tsx$/)
// Vite 5.x / 6.x — eager glob import
const pages = import.meta.glob('./pages/*.tsx', { eager: true });

Step 4: process.env to import.meta.env, DefinePlugin to define

Webpack injects env via DefinePlugin and process.env. Vite exposes only VITE_-prefixed vars on import.meta.env; everything else must move to define or a VITE_ rename.

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  define: {
    // DefinePlugin({ __APP_VERSION__: JSON.stringify('1.4.0') }) becomes:
    __APP_VERSION__: JSON.stringify('1.4.0'),
    // Shim libraries that still read process.env.NODE_ENV at runtime:
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
  },
  server: {
    // Webpack devServer.proxy becomes server.proxy
    proxy: {
      '/api': { target: 'http://localhost:3001', changeOrigin: true },
    },
  },
  build: {
    outDir: 'dist',           // mirror Webpack output.path
    sourcemap: true,
    rollupOptions: {
      // Webpack's optimization.splitChunks cacheGroups -> manualChunks
      output: { manualChunks: { vendor: ['react', 'react-dom'] } },
    },
  },
});

In source, rename process.env.API_URL to import.meta.env.VITE_API_URL and move the value into .env:

# .env — only VITE_-prefixed vars reach client code
VITE_API_URL=https://api.example.com

For the full env-mode model — .env.production, loadEnv, and why bare process.env reads return undefined in the client — see Environment Variables and Build Modes in Vite.

Step 5: Dev-Server Proxy and Static Assets

The proxy block above replaces devServer.proxy; the option names (target, changeOrigin, rewrite) come from the same http-proxy library, so most configs port verbatim. Webpack’s static/contentBase directory becomes Vite’s public/ folder, served at / and copied to dist/ untouched.

Verification

  1. Dev parity: vite boots and the app renders without require is not defined, process is not defined, or Failed to resolve import in the console.
  2. Env values: console.log(import.meta.env.VITE_API_URL) prints the value; bare process.env.API_URL is now undefined (expected).
  3. Build parity: vite build produces dist/ with hashed chunks; diff the asset list against the Webpack output and confirm vendor chunking matches your manualChunks.
  4. Preview: vite preview serves dist/ so you can smoke-test the production bundle before swapping CI.
# Vite 5.x / 6.x, Node 20+
vite build && vite preview --port 4173

Gotchas & Edge Cases

  • process is not defined in the browser. A dependency reads process.env at runtime. Add the 'process.env.NODE_ENV' shim shown above; for libraries needing the whole object, define: { 'process.env': {} } as a last resort.
  • __dirname / __filename in client code. These are Node-only; Webpack polyfilled them, Vite does not. Replace with import.meta.url or move the logic to the server.
  • Node core modules imported in the browser. Webpack 4 auto-polyfilled crypto, buffer, etc.; Webpack 5 dropped that, and Vite never had it. Install browser shims explicitly or remove the import.
  • CommonJS-only dependency fails to optimize. If a package errors during pre-bundling, list it in optimizeDeps.include to force esbuild conversion, or build.commonjsOptions.include for the build pass.
  • Aliases. Move Webpack resolve.alias to resolve.alias in vite.config.ts — same shape, but Vite expects absolute paths (use fileURLToPath(new URL('./src', import.meta.url))).