How to Migrate from Webpack to Vite: A Step-by-Step Guide
·PkgPulse Team
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
Step 1: Install Vite
# 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
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
# 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
# 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
| 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
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
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 the live comparison
View vite vs. webpack on PkgPulse →