Migrate from Webpack to Vite: A Step-by-Step Guide 2026
TL;DR
Most Webpack → Vite migrations take 2-4 hours and result in 10x faster dev server startup. The biggest changes: environment variables (REACT_APP_ → VITE_), process.env → import.meta.env, index.html moves to project root, and Webpack-specific loaders need Vite plugin equivalents. If you're on Create React App, this guide covers that migration specifically. For existing Webpack configs, 90% of plugins have Vite equivalents.
Key Takeaways
- Dev server: 8,000ms → 200ms — Vite's ESM-native dev server vs Webpack's full bundle
- HMR: 400ms → 20ms — esbuild transforms vs Babel, plus ESM module graph
process.env.REACT_APP_X→import.meta.env.VITE_X— env var migration requiredindex.htmlmoves to root — Vite serves from root, notpublic/- Most plugins have equivalents — see mapping table below
Why Migrate from Webpack to Vite Now
Webpack was built in an era when browsers didn't support ES modules natively, and bundling all your JavaScript into a single file was the only viable approach for production. That constraint drove Webpack's entire architecture: parse the entire module graph, transform every file through loaders, concatenate everything into bundles. In development, Webpack rebuilds large portions of that bundle on every change, which is why dev servers on non-trivial projects often take 10-30 seconds to start and 500ms or more to reflect a saved change.
Vite was designed for a different world. Modern browsers handle ES modules directly — they can request individual files on demand. Vite's dev server exploits this: instead of bundling anything, it serves your source files directly as ES modules (with esbuild transforming TypeScript and JSX on the fly at native speed). When you change a file, only that file is re-sent to the browser. The module graph doesn't need to be rebuilt. This is why Vite's dev server starts in under a second and why HMR feels nearly instantaneous even in large projects.
For production builds, Vite uses Rollup under the hood, which produces highly optimized output with superior tree-shaking compared to Webpack 4. The bundled output is comparable in size to Webpack's production builds, so you get all the dev-speed benefits without sacrificing production performance.
The migration surface area is manageable for most projects. The mechanical changes — config format, env variable prefixes, index.html location — can typically be completed in a few hours. The edge cases (CommonJS dependencies, Node.js polyfills, legacy code using global injection) require more attention, and that's what this guide focuses on.
Step 1: Install Vite
Start by installing Vite and its framework-specific plugin. For React projects, @vitejs/plugin-react uses Babel for Fast Refresh and JSX transformation (which is what you want for maximum CRA compatibility). There's also @vitejs/plugin-react-swc which uses SWC instead of Babel — SWC is 3-5x faster for large projects, at the cost of slightly narrower Babel plugin compatibility. If you're not using custom Babel transforms, @vitejs/plugin-react-swc is the better choice.
After installing Vite, uninstall Webpack (or react-scripts for CRA projects) to keep your devDependencies clean. Running both in a project simultaneously is unnecessary and can cause confusion about which tool is actually running.
# Install Vite and framework plugin
npm install -D vite @vitejs/plugin-react
# Or for Vue:
npm install -D vite @vitejs/plugin-vue
# Remove Webpack and CRA (if applicable)
# For CRA projects:
npm uninstall react-scripts
# For custom Webpack setup:
npm uninstall webpack webpack-cli webpack-dev-server \
babel-loader @babel/core @babel/preset-env @babel/preset-react \
css-loader style-loader file-loader url-loader \
html-webpack-plugin mini-css-extract-plugin
Step 2: Create vite.config.ts
vite.config.ts replaces your entire webpack.config.js (and babel.config.js, and postcss.config.js references, and craco.config.js if you were using CRACO with CRA). It's significantly shorter because Vite handles TypeScript, JSX, CSS, and static asset imports out of the box — no loaders needed. The config's main job is declaring your plugins and tuning the dev server and build settings.
The @vitejs/plugin-react plugin does the work that Babel used to handle: it transforms JSX, enables React Fast Refresh for HMR, and handles the React-specific runtime. You don't need @babel/preset-react or @babel/preset-env anymore. For TypeScript, Vite uses esbuild to strip types (it doesn't type-check — tsc in your build script handles that separately, which is why the build script reads tsc && vite build).
The proxy configuration replaces webpack-dev-server's proxy setting with an equivalent structure. If you had complex proxy rules in your Webpack config, they map directly to Vite's server.proxy object with the same semantics.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths'; // For path aliases
export default defineConfig({
plugins: [
react(), // Handles JSX, React Fast Refresh (HMR)
tsconfigPaths(), // Reads paths from tsconfig.json
],
// Dev server config
server: {
port: 3000, // Match your old Webpack port
open: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
// Build config
build: {
outDir: 'build', // CRA default was 'build'
sourcemap: true,
target: 'es2020',
},
// Define global constants (replaces Webpack DefinePlugin)
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
},
});
Step 3: Update package.json Scripts
// Before (CRA):
{
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}
}
// After (Vite):
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run"
}
}
Step 4: Move index.html to Project Root
This is the most conceptually significant change in the Vite migration. In Webpack (and CRA), index.html is a template that lives in public/ — HtmlWebpackPlugin processes it at build time, injecting the bundle script tags. Vite inverts this relationship: index.html is the entry point, and it lives at the project root. The <script type="module" src="/src/index.tsx"> tag is explicit and always present in the HTML, not injected by a plugin.
The practical effect is that Vite's dev server understands your application by reading index.html first, then following the module graph from the entry script. This is fundamentally different from Webpack's approach of reading your entry point from the config and treating HTML as an output artifact.
For CRA projects, the migration involves moving the file and adding the explicit script tag. For projects with multiple HTML files or custom HTML templates, Vite's rollupOptions.input supports multi-entry builds.
# CRA puts index.html in /public
# Vite serves index.html from root
mv public/index.html ./index.html
# Update the script tag in index.html:
# Before (CRA): <script> is injected by html-webpack-plugin, no explicit tag needed
# After (Vite): add a script tag pointing to your entry:
<!-- index.html (now in root) -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
<!-- Public assets: reference with / directly -->
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<div id="root"></div>
<!-- Add this script tag — Vite requires explicit entry point -->
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
Step 5: Migrate Environment Variables
Environment variable handling is the most tedious part of the migration for CRA projects, because every reference to process.env.REACT_APP_* in your codebase needs to become import.meta.env.VITE_*. This is a straightforward rename, but it's pervasive — a typical CRA project touches environment variables in dozens of files. Run a global find-and-replace in your editor (search for process.env.REACT_APP_, replace with import.meta.env.VITE_) and then rename the variables in your .env files to match.
The import.meta.env object also provides some useful built-ins that replace common CRA patterns. import.meta.env.DEV is a boolean (not the string 'development'), so comparisons like if (import.meta.env.DEV) work cleanly. import.meta.env.MODE gives you the current mode string ('development', 'production', or a custom mode). Environment variables not prefixed with VITE_ are intentionally invisible to client code — this is a security feature that prevents accidentally exposing server secrets.
TypeScript users should extend the ImportMetaEnv interface to get type-safe access to their custom env vars. Vite creates src/vite-env.d.ts automatically — add your variable declarations there to get autocomplete and type checking throughout the project.
# Before: Create React App env vars
REACT_APP_API_URL=https://api.example.com
REACT_APP_FEATURE_FLAG=true
# After: Vite env vars (rename your .env files)
VITE_API_URL=https://api.example.com
VITE_FEATURE_FLAG=true
# Access pattern changes too:
// Before (CRA / Webpack):
const apiUrl = process.env.REACT_APP_API_URL;
const isDev = process.env.NODE_ENV === 'development';
// After (Vite):
const apiUrl = import.meta.env.VITE_API_URL;
const isDev = import.meta.env.DEV; // Boolean, not string
const isProd = import.meta.env.PROD;
// TypeScript: add type declarations
// src/vite-env.d.ts (Vite creates this automatically)
/// <reference types="vite/client" />
// For custom env vars, extend the interface:
// src/vite-env.d.ts
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_FEATURE_FLAG: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Step 6: Map Webpack Plugins → Vite Plugins
Most Webpack plugins have direct Vite equivalents, and several of them are no longer needed because Vite handles the functionality natively. HtmlWebpackPlugin is completely replaced by Vite's index.html-first architecture. MiniCssExtractPlugin is unnecessary because Vite extracts CSS into separate files automatically in production builds. DefinePlugin's functionality moves to the define key in vite.config.ts.
The plugins you will need to explicitly install are the ones that handle non-standard asset transformations. SVG imports (if you were using @svgr/webpack to import SVGs as React components) need vite-plugin-svgr. Asset copying that doesn't fit Vite's default public directory behavior needs vite-plugin-static-copy. Bundle analysis needs rollup-plugin-visualizer in place of webpack-bundle-analyzer.
For unusual Webpack loaders without direct Vite equivalents, check whether the functionality can be handled by a Rollup plugin instead — Vite's plugin system is compatible with most Rollup plugins, significantly expanding the ecosystem available to you.
| Webpack Plugin | Vite Equivalent | Notes |
|---|---|---|
| HtmlWebpackPlugin | Built-in | index.html at root, no config needed |
| MiniCssExtractPlugin | Built-in | CSS extracted automatically in build |
| DefinePlugin | define in config | Same purpose |
| CopyWebpackPlugin | vite-plugin-static-copy | Copy non-standard assets |
| BundleAnalyzerPlugin | rollup-plugin-visualizer | Bundle analysis |
| svg (file-loader) | vite-plugin-svgr | Import SVGs as React components |
| @loadable/component | React.lazy + dynamic import | Vite handles code splitting natively |
| webpack-dev-server proxy | server.proxy in config | Built-in |
| EnvironmentPlugin | import.meta.env | No plugin needed |
# Install Vite plugin equivalents you need
npm install -D vite-plugin-svgr # SVG → React components
npm install -D vite-plugin-static-copy # Copy static assets
npm install -D rollup-plugin-visualizer # Bundle analysis
// Updated vite.config.ts with common plugins
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import svgr from 'vite-plugin-svgr';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
tsconfigPaths(),
svgr(), // import { ReactComponent as Logo } from './logo.svg'
// Bundle analysis: run `vite build --mode analyze`
process.env.ANALYZE === 'true' && visualizer({
open: true,
gzipSize: true,
}),
].filter(Boolean),
});
Common Gotchas
The gotchas in Webpack-to-Vite migrations fall into a predictable set of categories. Understanding the root cause of each one makes them easier to debug when you encounter them — and you will encounter at least one or two of these in any non-trivial project.
Gotcha 1: CommonJS require() in ESM
// Vite is ESM-first. Dynamic require() doesn't work:
// ❌ This fails in Vite:
const config = require('./config.json');
// ✅ Use ESM import:
import config from './config.json';
// ✅ Or dynamic import:
const config = await import('./config.json');
Gotcha 2: Global Variables
// Webpack injects global variables automatically
// Vite doesn't — you need to declare them
// ❌ global.Buffer used in some legacy packages fails in browser
// ✅ Fix with vite-plugin-node-polyfills:
import { nodePolyfills } from 'vite-plugin-node-polyfills';
plugins: [nodePolyfills({ protocolImports: true })]
// Or shim specifically:
define: {
global: 'window',
'process.env': '{}',
}
Gotcha 3: CSS Modules with Webpack-style classNames
// Webpack CSS Modules:
import styles from './Button.module.css';
// styles.primaryButton → "Button_primaryButton__abc123"
// Vite CSS Modules — same import syntax, same result
// No changes needed for standard CSS Modules usage
// Difference: Vite uses a different hash algorithm
// This means class names will change in your CSS snapshots
// Update snapshot tests: npx vitest run --update-snapshots
Handling Legacy Dependencies That Don't Support ESM
The most time-consuming part of some Webpack-to-Vite migrations is dealing with dependencies that still ship CommonJS-only bundles. Webpack's module resolution handled CJS transparently — it could require a CommonJS module anywhere, even within otherwise ESM code. Vite's dev server, being ESM-native, needs to transform CJS packages before it can serve them.
Vite handles this automatically for most packages via its dependency pre-bundling step (powered by esbuild). On first dev server startup, Vite scans your dependencies, identifies which ones are CJS-only, and bundles them into ESM-compatible format in the .vite cache directory. You don't need to configure anything for packages that esbuild can handle.
Problems arise when esbuild's CJS-to-ESM transform fails for a particular package — typically older packages that rely on dynamic require() calls, conditional module.exports, or packages that have side effects tied to Node.js module loading order. The error is usually a build-time Error: require is not defined or SyntaxError: Cannot use import statement outside a module deep in a package's source.
The fix is adding the problematic package to Vite's optimizeDeps.include list if it's a dependency, or to build.commonjsOptions.transformMixedEsModules for more complex scenarios. The vite-plugin-commonjs plugin handles edge cases that esbuild can't. In the worst case, you can set ssr.noExternal to force server-side rendering scenarios to bundle specific packages.
The practical approach is to start the dev server, note which packages produce errors, and work through them one at a time. Most dependencies that worked in Webpack will work with Vite after pre-bundling. The edge cases are genuinely edge cases — not the common path.
After the Vite Migration: Switch to Vitest
If your project uses Jest for testing, Vite's migration creates an opportunity to also switch to Vitest, which uses the same Vite pipeline and configuration. Vitest runs in the same environment as your application, which eliminates a whole class of "works in tests but not in browser" problems that arise when Jest uses its own module resolution distinct from your bundler.
The Vitest API is designed to be a drop-in replacement for Jest: describe, it, expect, beforeEach, afterEach all work identically. vi.fn() replaces jest.fn(), and vi.mock() replaces jest.mock(). Most Jest test files migrate with a global find-and-replace. The main difference is configuration: instead of jest.config.js, you add a test block to vite.config.ts, which means your test environment automatically inherits all your Vite plugins, path aliases, and environment variables.
The one case where Jest still makes sense post-migration is if you're heavily using Jest's fake timers and timer mocking with complex scheduling — Vitest's fake timer implementation is very close but has some edge case differences. For the vast majority of test suites, Vitest is the right call and completing both migrations in parallel is worth the slightly larger up-front investment.
Verify the Migration
# 1. Start dev server
npm run dev
# Should start in <1 second, open at localhost:3000
# 2. Check production build
npm run build
npm run preview
# Preview the production build locally
# 3. Run tests
npm test
# Verify test suite passes (may need Jest → Vitest migration too)
# 4. Check bundle size
ANALYZE=true npm run build
# Compare to Webpack output
Compare Vite and Webpack package health on PkgPulse.
See also: Bun vs Vite and Turbopack vs Vite, Migrating Webpack to Vite in 2026: Step-by-Step for Large Apps.
See the live comparison
View vite vs. webpack on PkgPulse →