Webpack to Vite Migration: Large Codebases 2026
Migrating a small app from Webpack to Vite takes an afternoon. Migrating a large codebase — 200K+ LOC, dozens of custom Webpack plugins, Module Federation micro-frontends, a legacy CJS codebase — takes weeks or months of careful planning. The performance gains are real (50-100x faster dev server startup on large projects), but the path isn't "swap bundler, done."
This guide is for the teams that can't just follow the 5-step migration tutorial. It covers incremental migration strategies, the blockers you'll actually hit, and how to handle the edge cases that sink large migrations.
TL;DR
The key insight for large codebase migrations: don't do it all at once. Use Vite's legacy and commonjs plugins to handle the 80% that "just works," create a compatibility shim layer for custom Webpack behaviors, and migrate Module Federation last (it's the hardest part). Expect 2-6 weeks for a 100K+ LOC production app with a dedicated team. The dev server speedup (from 60+ seconds to under 2 seconds) makes the investment worth it for most teams.
Key Takeaways
- Dev server startup: Webpack 60-120s → Vite <2s on 100K+ LOC projects — the primary motivator
- Vite 8 + Rolldown (March 12, 2026): Rust-based unified bundler replaces both Rollup (prod) and esbuild (dev) — 10-30x faster production builds, true dev/prod parity
- Biggest blockers:
require()in app code, Module Federation, custom Webpack loaders with no Vite equivalent,process.envpatterns, circular dependencies - Incremental strategy: run Webpack (production) + Vite (development) simultaneously during migration
- Module Federation: use
@module-federation/viteplugin — functionally equivalent but requires config migration - CommonJS:
vite-plugin-commonjshandles most cases; true CJS app code needs rewriting
At a Glance: What Changes
| Webpack Pattern | Vite Equivalent | Notes |
|---|---|---|
process.env.REACT_APP_X | import.meta.env.VITE_X | Prefix change required |
webpack.DefinePlugin | define in vite.config.ts | Same purpose, different API |
require('./file') (dynamic) | await import('./file') | CJS → ESM |
require.context() | import.meta.glob() | Glob imports |
webpack.ProvidePlugin | vite-plugin-node-polyfills | Node.js polyfills |
ModuleFederationPlugin | @module-federation/vite | Plugin available |
url-loader / file-loader | Built-in (Vite handles assets natively) | No plugin needed |
babel-loader | @vitejs/plugin-react or @vitejs/plugin-vue | Framework plugin |
| Custom loaders | Custom Vite plugins (transform hook) | Different API |
splitChunks | build.rollupOptions.output.manualChunks | Different syntax |
Phase 1: Audit Before You Migrate
Before touching config files, run a full audit to understand what you're dealing with.
Audit Checklist
# 1. Find all require() calls in application code (not just node_modules)
grep -r "require(" src/ --include="*.js" --include="*.ts" --include="*.jsx" --include="*.tsx" | wc -l
# 2. Find require.context() usage (Webpack-specific)
grep -r "require\.context" src/ --include="*.js" --include="*.ts"
# 3. Find process.env usage (needs prefix migration)
grep -r "process\.env\." src/ --include="*.js" --include="*.ts" | grep -v "NODE_ENV" | wc -l
# 4. List all Webpack plugins (each needs a Vite equivalent or workaround)
cat webpack.config.js | grep -A2 "plugins:"
# 5. Find circular dependencies (will fail loudly in Vite)
npx madge --circular src/ --extensions ts,tsx,js,jsx
# 6. Find dynamic requires (hardest to migrate)
grep -r "require(variable\|require(path\.\|require(\`" src/ --include="*.js" --include="*.ts"
Common Audit Findings in Large Codebases
| Issue | Frequency | Migration Effort |
|---|---|---|
process.env references | Very high | Low (search/replace) |
Static require() calls | High | Medium (script + manual review) |
Dynamic require() calls | Medium | High (case-by-case) |
require.context() | Medium | Low (glob equivalent) |
| Custom loaders | Varies | High (rewrite as Vite plugins) |
| Module Federation | Medium | High (dedicated phase) |
| Circular dependencies | Often discovered | High (fix the code) |
| Node.js built-in polyfills | Medium | Low (polyfill plugin) |
Phase 2: The Incremental Migration Strategy
Don't replace Webpack overnight. For large teams, the safest approach is running both bundlers simultaneously during migration.
Strategy: Vite for Dev, Webpack for Prod
Week 1-2: Get Vite running in development mode
Week 3-4: Resolve blockers, migrate most files
Week 5-6: Switch production builds to Vite
Week 7+: Remove Webpack
// package.json — dual bundler scripts during migration
{
"scripts": {
"dev": "vite", // ← New: Vite dev server
"dev:webpack": "webpack serve", // ← Keep: fallback during migration
"build": "webpack --config webpack.config.js", // ← Still using Webpack for prod
"build:vite": "vite build", // ← Test Vite prod builds incrementally
"build:types": "tsc --noEmit"
}
}
This means developers get the fast Vite dev server immediately, while production continues shipping from Webpack until you're confident the Vite build is correct.
Phase 3: Install and Configure Vite
npm install -D vite @vitejs/plugin-react
# Or for Vue: @vitejs/plugin-vue
# For legacy browser support: @vitejs/plugin-legacy
// vite.config.ts — starting template for large React apps
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [react()],
// Path aliases (mirrors webpack resolve.alias)
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@components': resolve(__dirname, './src/components'),
'@utils': resolve(__dirname, './src/utils'),
// Add your existing webpack aliases here
},
},
// Environment variables (replaces webpack.DefinePlugin)
define: {
// Migrate process.env.NODE_ENV usage
'process.env.NODE_ENV': JSON.stringify(mode),
// Legacy support while migrating (removes gradually)
__WEBPACK_DEV_SERVER__: JSON.stringify(false),
},
// Dev server config
server: {
port: 3000,
proxy: {
// Proxy API calls during development
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
// Build config
build: {
rollupOptions: {
output: {
// Manual chunks to match webpack's splitChunks behavior
manualChunks: {
vendor: ['react', 'react-dom'],
// Add other large dependencies
},
},
},
},
}
})
Phase 4: The Hard Blockers
Blocker 1: CommonJS require() in App Code
Vite's dev server serves ES modules natively. require() in your app code (not node_modules) will fail.
Approach A: Plugin-based compatibility (fast, temporary)
npm install -D vite-plugin-commonjs
// vite.config.ts
import commonjs from 'vite-plugin-commonjs'
export default defineConfig({
plugins: [
commonjs(), // Converts require() calls to import() at dev time
react(),
],
})
This buys time but doesn't fix the underlying issue. Create a script to systematically migrate:
# Use codemod to migrate static requires
npx codemod --transform @codemod/transform-cjs-to-esm src/
# Review and manually fix edge cases
Approach B: Codemod the requires (permanent)
// Before (CommonJS)
const { utils } = require('./utils')
const config = require('../config.json')
const LazyComponent = require('./LazyComponent').default
// After (ESM)
import { utils } from './utils'
import config from '../config.json' with { type: 'json' }
import LazyComponent from './LazyComponent'
Blocker 2: require.context() for Glob Imports
Webpack's require.context() is commonly used to dynamically import multiple modules:
// Before (Webpack)
const context = require.context('./components', true, /\.vue$/)
const modules = context.keys().map(key => context(key))
// After (Vite)
const modules = import.meta.glob('./components/**/*.vue', { eager: true })
// OR lazy (returns functions returning Promises)
const lazyModules = import.meta.glob('./components/**/*.vue')
// Common pattern: auto-register Vue/React components
// Before (Webpack)
const requireComponent = require.context('./components', false, /Base[A-Z]\w+\.(vue|js)$/)
requireComponent.keys().forEach(fileName => {
const component = requireComponent(fileName)
const componentName = path.basename(fileName, path.extname(fileName))
app.component(componentName, component.default || component)
})
// After (Vite)
const components = import.meta.glob('./components/Base*.vue', { eager: true })
Object.entries(components).forEach(([path, module]) => {
const componentName = path.split('/').pop().replace('.vue', '')
app.component(componentName, module.default)
})
Blocker 3: process.env Variable Migration
The rename from REACT_APP_ to VITE_ is the most common migration task:
# Find all env var references in your codebase
grep -r "process\.env\.REACT_APP_" src/ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" -l
# Batch rename with sed (macOS)
find src/ -type f -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" | \
xargs sed -i '' 's/process\.env\.REACT_APP_/import.meta.env.VITE_/g'
# Don't forget .env files
find . -name ".env*" -not -path "*/node_modules/*" | \
xargs sed -i '' 's/^REACT_APP_/VITE_/g'
Blocker 4: Module Federation
Module Federation is the hardest part of large codebase migrations. The @module-federation/vite plugin provides a compatible API:
npm install -D @module-federation/vite
// Shell app vite.config.ts
import { federation } from '@module-federation/vite'
export default defineConfig({
plugins: [
federation({
name: 'shell',
remotes: {
// Same as webpack's ModuleFederationPlugin remotes
'mfe-products': 'http://localhost:3001/remoteEntry.js',
'mfe-cart': 'http://localhost:3002/remoteEntry.js',
},
shared: ['react', 'react-dom'],
}),
],
build: {
target: 'esnext', // Required for Module Federation
},
})
// Remote app vite.config.ts
import { federation } from '@module-federation/vite'
export default defineConfig({
plugins: [
federation({
name: 'mfe-products',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductDetail': './src/components/ProductDetail',
},
shared: ['react', 'react-dom'],
}),
],
})
Known limitation: @module-federation/vite works well for production builds but has some rough edges in Vite's dev server. Test thoroughly before cutting over.
Blocker 5: SVG and Asset Imports
If you're using @svgr/webpack to import SVGs as React components, you'll hit this immediately:
npm install -D vite-plugin-svgr
// vite.config.ts
import svgr from 'vite-plugin-svgr'
export default defineConfig({
plugins: [svgr(), react()],
})
// After: use ?react query suffix
import Logo from './logo.svg?react'
// Or configure default to always treat SVGs as components:
// svgr({ include: '**/*.svg' }) ← then import without suffix
For file-loader / url-loader patterns (inline base64, asset URLs): Vite handles these natively. Import any asset and you get its URL:
import logoUrl from './logo.png' // '/assets/logo-a1b2c3.png'
Blocker 6: Custom Webpack Loaders
Each custom Webpack loader needs to be rewritten as a Vite plugin:
// Webpack loader: my-svg-loader.js
module.exports = function(source) {
return source.replace(/fill="#[0-9a-fA-F]{6}"/g, 'fill="currentColor"')
}
// Vite plugin equivalent:
// vite.config.ts
function svgCurrentColorPlugin() {
return {
name: 'svg-current-color',
transform(code, id) {
if (!id.endsWith('.svg')) return
return code.replace(/fill="#[0-9a-fA-F]{6}"/g, 'fill="currentColor"')
},
}
}
export default defineConfig({
plugins: [svgCurrentColorPlugin(), react()],
})
Vite 8 + Rolldown: The Large Codebase Game-Changer
Vite 8 (released March 12, 2026) ships Rolldown as a unified bundler, replacing both Rollup (production) and esbuild (development transforms). Rolldown is built in Rust by the VoidZero team — the impact on large codebases is significant:
Real-world production results from Vite 8's March 2026 GA release:
Real-world build improvements (Vite 8 + Rolldown):
Linear's production build: 46s → 6s (7.7x faster)
Generic large project (500+ modules): 10-30x faster builds
HMR patch time: ~60s → <50ms
Memory usage: up to 80% reduction vs Webpack
Dev/prod parity: same Rolldown engine in both modes
→ Eliminates entire class of "works in dev, breaks in prod" issues
Vite 8 requires Node.js 20.19+ or 22.12+. Audit your .nvmrc, CI base images, and engines field in package.json before upgrading.
Vite 8 Breaking Changes for Large Apps
If you're migrating directly to Vite 8, or upgrading from Vite 7:
// Vite 7: rollupOptions
build: {
rollupOptions: {
output: { manualChunks: { vendor: ['react', 'react-dom'] } }
}
}
// Vite 8: rolldownOptions (compat shim exists but explicit migration preferred)
build: {
rolldownOptions: {
output: {
advancedChunks: {
groups: [
{ name: 'react-vendor', test: /react|react-dom/ },
{ name: 'vendor', test: /node_modules/ },
]
}
}
}
}
Other Vite 8 changes that hit large apps:
- Stricter CJS interop — default imports from CJS packages may break; use
legacy.inconsistentCjsInterop: trueas a temporary shim - Custom plugin hooks —
moduleType: 'js'required in sometransformhooks - HMR API change —
import.meta.hot.acceptno longer accepts a URL string
Recommended two-step upgrade: Install rolldown-vite as a drop-in first to validate Rolldown-specific issues, then upgrade to Vite 8 for the remaining Vite-level breaking changes.
If you're evaluating whether to migrate in 2026, Vite 8 + Rolldown closes the last remaining criticism of Vite for large apps. The case for migration is now stronger than ever.
Monorepo Considerations
For Nx, Turborepo, or custom monorepos:
// packages/web-app/vite.config.ts — shared config pattern
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// Reference sibling packages directly in monorepo
'@company/ui': resolve(__dirname, '../../packages/ui/src'),
'@company/utils': resolve(__dirname, '../../packages/utils/src'),
},
},
// Optimize pre-bundling for monorepo packages
optimizeDeps: {
include: ['@company/ui', '@company/utils'],
},
})
// turbo.json — include vite cache
{
"pipeline": {
"build": {
"outputs": [".vite/**", "dist/**"],
"inputs": ["src/**", "vite.config.ts", "index.html"]
}
}
}
Migration Metrics: What to Expect
Real-world and community migration reports for large codebases:
| Project / Metric | Before (Webpack) | After (Vite 8) | Source |
|---|---|---|---|
| Linear prod build | 46s | 6s (7.7x) | Vite 8 release notes |
| Swimm web app cold start | 2+ min | Near-instant | Swimm blog |
| ~500 components / 150K LOC HMR | ~60s | <50ms | Community benchmark |
| Generic dev cold start | 60-120s | 0.5-2s | Multiple reports |
| Generic prod build (Vite 8) | 45-90s | 3-8s | Multiple reports |
| CI build time | 2-4 min | 20-40s | Multiple reports |
| Memory usage | Baseline | -50-80% | Rolldown benchmarks |
| Bundle size | Baseline | -5-15% (tree-shaking) | Multiple reports |
Bundle Analysis Migration
Replace webpack-bundle-analyzer with rollup-plugin-visualizer:
npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
visualizer({ filename: 'dist/stats.html', open: true }),
],
})
Run vite build and dist/stats.html opens with an interactive treemap — same workflow as webpack-bundle-analyzer.
Compare Vite vs Webpack download trends on PkgPulse.
Related: Vite vs Rspack vs Webpack 2026 · How to Migrate Webpack to Vite · Vite vs Webpack: Is Migration Worth It?
See the live comparison
View vite vs. webpack on PkgPulse →