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.

The problem this migration solves is start-up and rebuild latency. Webpack builds a single dependency graph and bundles the whole thing before it can serve the first byte, so cold start scales with the size of the graph and a warm HMR update scales with the number of modules in the changed chunk. On a large app that means multi-second dev-server boots and edit-to-refresh times that grow as the codebase grows. Vite sidesteps the bundle in development entirely: it hands index.html to the browser, the browser requests the entry module, and Vite transforms and serves each imported file on demand over native ESM. The graph is walked lazily by the browser instead of eagerly by the bundler, so boot time is roughly constant regardless of app size, and an HMR update re-transforms exactly one module.

Two things pay for that speed, and both are where migrations break. First, Vite pre-bundles your node_modules dependencies once with esbuild (this is optimizeDeps), converting CommonJS and UMD packages to ESM and collapsing packages with hundreds of internal files into a single request so the browser is not flooded. Second, your own source is never bundled in dev, which means it must already be valid ESM the browser can execute — no implicit CommonJS interop, no webpack-specific runtime globals, no loader magic resolving non-JS imports. Production is a separate world again: vite build runs Rollup, which does bundle, tree-shake, and hash. So you are really validating parity across two different pipelines, and a page that works in vite dev can still fail in vite build (and vice versa). Plan to test both.

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.

The reason for the parallel-run is reversibility. If you delete Webpack on day one and hit a loader you cannot cleanly port, you have no working build and a growing pile of half-finished changes. Keeping build:webpack green means every intermediate commit still ships, and the risky step — deleting the old config, webpack, webpack-cli, html-webpack-plugin, and every *-loader — collapses into a single diff you can revert with git revert if CI goes red. Do the deletion last and do it atomically; a half-removed Webpack leaves webpack-specific globals and require calls that neither tool resolves.

Pin the versions you are targeting before you start. Vite 5 needs Node 18+ and Vite 6 needs Node 20+; running on an older Node silently disables features like top-level await handling and produces confusing resolve errors. Check node -v in CI, not just locally, because a mismatched CI Node is the most common reason a migration that works on a laptop fails in the pipeline.

Parallel-run migration strategy Keep Webpack building while adding Vite, run both until the Vite build reaches parity, then remove webpack.config.js and the Webpack dependencies in a single commit. add Vite alongsideWebpack still builds run both to paritydiff the outputs delete Webpackone commit No big-bang cutover — parity first, deletion last
Figure: the safe path keeps a working build at every step; the risky part is a single reversible 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">.

Under the hood this is not cosmetic. Vite treats HTML as a first-class module graph root: it parses index.html, finds every <script type="module" src> and <link rel="stylesheet">, and treats those URLs as graph entry points. In dev it rewrites the src to a served source path; in the production build Rollup uses the same references to decide what to bundle and then rewrites the tags to hashed output filenames with the correct crossorigin and modulepreload attributes. Because the HTML is the source of truth, anything Webpack did through HtmlWebpackPlugin options — template variables, injected chunks, multiple HTML outputs — has to be expressed in the HTML file itself or through a plugin that operates on it. A multi-page Webpack setup with several HtmlWebpackPlugin instances becomes several root HTML files listed under build.rollupOptions.input.

Entry inversion: JS-first versus HTML-first Webpack starts from a JS entry and injects a script tag into HTML via HtmlWebpackPlugin; Vite starts from index.html at the root, which references the source directly with a module script. Webpack: JS entry → inject HTML main.js entry HtmlWebpackinjects tag Vite: index.html → module script index.html root <script module>/src/main.tsx The HTML is the entry now — no plugin injects the bundle tag.
Figure: the arrow reverses — HTML references source, instead of a plugin writing a tag into HTML.
<!-- 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.

The %VITE_FOO% substitution is a literal string replacement Vite runs over the HTML at serve and build time, and it only sees VITE_-prefixed variables loaded from your .env files — the same visibility rule that governs import.meta.env. If you had EJS conditionals or loops in the Webpack template (<% if (production) %>), there is no equivalent; that logic must move into a small HTML-transform plugin using the transformIndexHtml hook, which receives the HTML string and returns a modified one. Reach for that hook only when static substitution genuinely cannot express the requirement, because a template that branches on build state is a common source of “works in dev, wrong in prod” bugs. For the common case — a title, a meta tag, an analytics key — a %VITE_TITLE% placeholder is enough.

Step 2: Loaders Become Built-in Transforms or Plugins

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

The reason so many vanish is that Vite bakes the common transforms into the server itself rather than exposing them as a configurable chain. JavaScript, TypeScript, and JSX are compiled by esbuild on the fly — esbuild strips types and lowers syntax in a single fast pass, which is why ts-loader and babel-loader usually have nothing to replace them. That speed comes with one caveat worth internalizing: esbuild transpiles each file in isolation and does not type-check, so a ts-loader setup that failed the build on type errors no longer does. Type-checking has to move to a separate tsc --noEmit step (or vite-plugin-checker) in CI; otherwise type regressions ship silently. CSS, Sass/Less/Stylus (once the preprocessor is installed), JSON, and ?raw/?url asset imports are likewise built in. What is left is the small set of transforms Vite does not know about.

Where Webpack loaders go in Vite Most loaders such as babel, css and file loaders disappear into Vite's built-in transforms; a few like svgr map to a dedicated plugin; and a bespoke loader becomes a custom plugin transform hook. gone (built-in)babel/ts, css, sass,file/url, raw (?raw) a pluginsvgr → vite-plugin-svgrknown ecosystem plugin custom transformbespoke loader →plugin transform hook Three destinations for every loader
Figure: most loaders vanish, a handful become named plugins, only the bespoke ones need real porting work.
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).

The mechanical difference between a loader and a plugin transform is worth spelling out because it decides how much rewriting you do. A Webpack loader is a function (source) => transformedSource bound to a file pattern in module.rules; loaders in a chain run right-to-left, each consuming the previous one’s output. A Vite plugin’s transform(code, id) hook is broadly the same signature — you get the file contents and its resolved id, you return new code (and ideally a sourcemap) — but ordering is controlled by enforce: 'pre' | 'post' and a resolveId/load pair rather than array position. So a single-purpose loader ports almost verbatim: guard on the file extension inside transform, do the work, return { code, map }. A loader chain collapses into one transform that does the steps in sequence, or into several plugins ordered with enforce. The one thing that does not carry over is loader options merging and this.async() callbacks; a Vite transform is just an async function that returns, which is simpler but means anything that depended on Webpack’s loader context (this.resourcePath, this.emitFile) needs the Rollup/Vite equivalent (id, this.emitFile on the plugin context).

Here is the smallest faithful port — a loader that inlined the contents of .txt files as a default export, rewritten as a plugin:

// vite.config.ts — Vite 5.x / 6.x
// Replaces: { test: /\.txt$/, use: 'raw-loader' } style custom loader
import type { Plugin } from 'vite';
import { readFile } from 'node:fs/promises';

function txtAsString(): Plugin {
  return {
    name: 'txt-as-string',
    enforce: 'pre', // run before Vite's own asset handling claims the id
    async transform(_code, id) {
      if (!id.endsWith('.txt')) return null; // null = "not mine, leave it"
      const raw = await readFile(id, 'utf-8');
      return { code: `export default ${JSON.stringify(raw)};`, map: null };
    },
  };
}

Returning null from transform is the idiomatic “pass” — it tells Vite this plugin does not handle the file, so other plugins and built-ins still get a turn. Forgetting that and returning the untouched code instead marks the module as transformed and can short-circuit later plugins, which is a subtle way to break the built-in CSS or asset pipeline.

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:

The asymmetry between “your source” and “your dependencies” trips people up, so it is worth being precise about why it exists. When the browser requests one of your source files, Vite transpiles it but does not bundle it, and the browser executes the result directly — a raw require('./foo') in that output is a call to a function that does not exist in the browser, hence require is not defined. Dependencies take a different path: before the first request, optimizeDeps runs esbuild over each package listed in your imports, and esbuild’s bundler understands CommonJS, so module.exports inside lodash or react-dom is resolved and re-emitted as ESM into node_modules/.vite. That is why a CommonJS package works untouched while a single require in your own src/ throws. The fix is a one-time codemod: import/export everywhere in your source, and for the dynamic-directory pattern, import.meta.glob, which Vite statically expands at build time into an object of path-to-importer entries.

import.meta.glob has two modes and choosing wrong changes bundle behavior. The default (lazy) form returns { './pages/a.tsx': () => import('./pages/a.tsx') } — each value is a dynamic import, so every match becomes its own code-split chunk, which is what you want for route-level splitting. Passing { eager: true } inlines the modules and returns their namespaces directly, equivalent to a static import of every file, with no splitting. Webpack’s require.context was eager by default, so a naive port to lazy glob will change your chunk graph; match the old behavior first, then decide whether lazy splitting is actually an improvement.

Two require patterns and their ESM replacements Top-level require and module.exports become import and export; require.context becomes import.meta.glob; CommonJS dependencies in node_modules are left alone because esbuild pre-bundling converts them. require / module.exports require.context() import / export import.meta.glob CommonJS deps in node_modules are fine — esbuild pre-bundles them.
Figure: only your own source must become ESM; the two require idioms have direct replacements.
// 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.

Both DefinePlugin and Vite’s define work the same way at the mechanical level: they are compile-time text substitutions, not runtime lookups. The build sees the token import.meta.env.VITE_API_URL or __APP_VERSION__ in your source and replaces it with the literal value before the code ever runs, which is why the replacement must be a fully-serialized expression — JSON.stringify('1.4.0'), not '1.4.0', so the injected text is the quoted string "1.4.0" rather than a bare identifier. Get that wrong and you inject 1.4.0 as three tokens or an undefined variable, and the failure shows up far from the config. The VITE_ prefix rule is a deliberate security boundary: because substitution is textual and the whole .env could contain secrets, Vite refuses to expose anything unprefixed to client code, so a server-only DATABASE_URL cannot leak into the bundle by accident. Renaming a client-read variable to VITE_-prefixed is you explicitly declaring “this value is safe to ship to the browser.”

Env and constant injection mapping process.env.X client reads become VITE_-prefixed import.meta.env; DefinePlugin constants become define entries; and a runtime process.env.NODE_ENV read needs an explicit define shim. process.env.API_URL DefinePlugin({__V__}) runtime process.env.NODE_ENV import.meta.env.VITE_API_URL define: { __V__: … } define shim (last resort)
Figure: two clean renames and one shim — the shim is only for libraries that read process.env at runtime.
// 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.

One config-file detail on the manualChunks line above deserves a note, because it is the direct replacement for Webpack’s optimization.splitChunks. Webpack’s cacheGroups let you carve vendor and shared code out of the graph by test regex and priority; Rollup’s manualChunks is a plainer mechanism — a map of chunk name to module list, or a function (id) => chunkName. The object form shown here pins react and react-dom into a single long-lived vendor chunk so their hash only changes on a dependency bump, which is the main caching win most splitChunks configs were after. If your Webpack setup split by node_modules path wholesale, use the function form and return a chunk name derived from the package, but do it deliberately: over-splitting produces dozens of tiny chunks and more request overhead, which on HTTP/2 can be slower than a few larger ones.

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.

Two behavioral differences hide behind the shared option names. First, rewrite in Vite is a function (path => path.replace(/^\/api/, '')), where some Webpack setups used the object pathRewrite; the semantics match but the shape differs, so copy the intent, not the literal key. Second, Webpack’s dev server historically proxied and served from the same origin with its own middleware stack; Vite’s proxy is Connect-based and WebSocket upgrades need ws: true explicitly if your API uses them, otherwise the socket handshake 404s while plain requests work — a confusing partial failure. For static assets, the important rule is that anything in public/ is served at the root by its literal name and is never processed, hashed, or renamed. That is the right home for robots.txt, a favicon, or a file some third-party script fetches by a fixed URL. Assets you import from source, by contrast, do go through the pipeline and get content-hashed, so do not move importable assets into public/ expecting cache-busting — you lose the hash and the fingerprint.

Dev-server proxy and static asset mapping devServer.proxy becomes server.proxy with the same http-proxy option names, and the Webpack static or contentBase directory becomes Vite's public folder served at the root. devServer.proxy static / contentBase server.proxy (same options) public/ served at /
Figure: the proxy ports almost verbatim; static files just move to public/.

Verification

Four parity checks before switching CI Confirm dev parity with no require or process errors, confirm env values resolve, confirm build parity by diffing the chunk list against Webpack, then preview the production bundle. 1 · dev parityno require/process err 2 · env valuesVITE_ resolves 3 · build paritydiff chunk list 4 · previewsmoke-test dist Prove parity in dev, env, build, and preview before deleting Webpack
Figure: four gates between "Vite runs" and "safe to switch CI".
  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

Treat these four as gates, not a checklist you can skip when the deadline is close. The dev and env checks catch the ESM and env-prefix mistakes, which surface immediately in the console. The build and preview checks catch the class of bug that only exists in the Rollup pipeline — a dependency that resolves in dev via optimizeDeps but fails Rollup’s stricter static analysis, a dynamic import that Rollup cannot split, or a define value that was fine as a dev global but produces invalid syntax after minification. vite preview is deliberately a dumb static file server: it serves exactly what CI will deploy, with no dev-server middleware papering over problems, which is why smoke-testing there is the last honest signal before you switch pipelines. If the preview build works and the chunk list matches, deleting Webpack is safe.

Gotchas & Edge Cases

Five common migration breakages process is not defined needs a NODE_ENV shim; __dirname is Node-only; Node core modules are not polyfilled; a CommonJS-only dependency may need optimizeDeps.include; and resolve.alias must use absolute paths. process is not definedadd the process.env.NODE_ENV shim __dirname / __filenameNode-only → use import.meta.url Node core in the browsernot polyfilled — shim or remove CommonJS-only dep failsadd to optimizeDeps.include resolve.aliassame shape as Webpack, but Vite expects absolute paths — fileURLToPath(new URL('./src', import.meta.url))
Figure: three browser-runtime breakages, one pre-bundle case, and the alias path gotcha.
  • process is not defined in the browser. Symptom: a runtime ReferenceError: process is not defined from inside a dependency, not your own code. Root cause: the library reads process.env.NODE_ENV (or another key) at runtime, and since Vite does not define a global process, the read throws. Fix: add the 'process.env.NODE_ENV' shim shown above, which replaces the exact read with a literal at build time; for a library that dereferences the whole object (process.env passed around, not just one key), define: { 'process.env': {} } is the last-resort blanket shim. Confirm by grepping the failing package for process.env and checking the injected value appears in the built chunk.
  • __dirname / __filename in client code. Symptom: __dirname is not defined at runtime. Root cause: these are CommonJS module-scope variables; Webpack synthesized them per-module, but ESM has no such globals and Vite does not fake them. Fix: for a real filesystem path in Node-side code use fileURLToPath(import.meta.url) and derive the directory from it; if the code was only computing a path to bundle an asset, replace it with a proper import or new URL('./file', import.meta.url). If the logic is genuinely server-only, it should not be in a browser bundle at all — move it. Confirm the symbol no longer appears in dist/.
  • Node core modules imported in the browser. Symptom: Module "buffer"/"crypto"/"stream" has been externalized warnings, then a runtime failure when the code touches the missing API. Root cause: Webpack 4 auto-polyfilled Node builtins for the browser, Webpack 5 removed that (requiring explicit resolve.fallback), and Vite never polyfilled them. Fix: either drop the dependency on the builtin, swap to a browser-native API (crypto.subtle for hashing), or install a shim (vite-plugin-node-polyfills, or map the specifier in resolve.alias to a browser package). Prefer removing the import; polyfilling Node core into the browser is weight you usually do not need. Confirm by exercising the code path that used the builtin.
  • CommonJS-only dependency fails to optimize. Symptom: a pre-bundle error at dev startup, or a named import that is undefined at runtime even though the package clearly exports it. Root cause: esbuild’s CJS-to-ESM interop occasionally misdetects a package’s named exports, especially older UMD bundles. Fix: list the package in optimizeDeps.include to force it through the pre-bundler, and for the production pass add it to build.commonjsOptions.include so Rollup’s commonjs plugin also converts it. Confirm the named import resolves in both vite dev and a vite build + vite preview.
  • Aliases. Symptom: Failed to resolve import for a path that worked under Webpack, e.g. @/components. Root cause: Webpack resolved relative alias targets against the project root, while Vite (Rollup) expects the alias target to be an absolute path. Fix: move resolve.alias across unchanged in shape but resolve each target absolutely with fileURLToPath(new URL('./src', import.meta.url)). Keep the alias keys identical to your tsconfig.json paths so the editor and the bundler agree. Confirm every aliased import resolves in a clean vite build, not just in dev where a stale cache can mask a bad alias.

Performance Considerations

The headline win is dev-server responsiveness, but the shape of the win is worth understanding so you can measure it. Cold start under Vite is dominated by the one-time optimizeDeps pre-bundle; after that first run the result is cached in node_modules/.vite keyed by your lockfile and config, so subsequent boots are near-instant until a dependency changes. If you see repeated slow starts, something is invalidating that cache — a postinstall that rewrites node_modules, a config value computed non-deterministically, or a CI that does not persist the cache directory. HMR cost is per-module and roughly constant, unlike Webpack where it scaled with chunk size, so the large-app edit latency that motivated the migration should drop from seconds to tens of milliseconds. Production build time, by contrast, is a Rollup number and will not necessarily beat Webpack — Rollup is thorough, not the fastest bundler — so judge the migration on dev experience and output quality, not on vite build wall-clock. If build time is the constraint, that is a separate lever (fewer plugins in the build lane, build.target tuning, or reserving heavy transforms for enforce: 'post').

CI Integration

The parallel-run strategy maps directly onto CI. Keep the existing Webpack job green and add a second job that runs vite build plus a tsc --noEmit type-check, because — as noted under loaders — esbuild does not type-check and that safety net has to be reinstated somewhere. Run both build jobs on the same Node version you pinned, and cache node_modules/.vite between runs so the pre-bundle is not recomputed every pipeline. Add a preview smoke test: vite build then a headless check against vite preview (or against the served dist/) to catch the Rollup-only failures that dev never surfaces. Only when the Vite job has been green for enough real commits to trust it should you delete the Webpack job — in the same atomic commit that removes webpack.config.js and the Webpack dependencies, so a revert restores a complete working pipeline rather than a half-migrated one.