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()],
})
TypeScript Path Aliases and tsconfig Migration
TypeScript path aliases configured in tsconfig.json under compilerOptions.paths require explicit reproduction in vite.config.ts as resolve.alias entries — Vite does not automatically read tsconfig.json path aliases during the dev server or build. This is one of the most common sources of "works in tsc but fails in Vite" errors during migration. The vite-tsconfig-paths plugin automates this: it reads your tsconfig.json (and the referenced tsconfig files in a monorepo) and applies all path aliases to Vite's resolver automatically. For large codebases with dozens of path aliases across multiple tsconfig files, this plugin is effectively mandatory during migration to avoid manual duplication and the drift that comes when alias additions are only added to one config.
Project references in TypeScript monorepos (where tsconfig.json files use references to point to sibling packages) interact with Vite's module resolution in ways that require careful configuration. Vite's optimizeDeps.include array should list internal workspace packages when their source TypeScript is resolved directly (rather than through their built dist/ output) — this tells Vite's esbuild pre-bundling step to process those packages as part of the dependency optimization, avoiding slow cold-start issues on first load. Teams using Nx or Turborepo that have configured TypeScript project references as the primary module resolution mechanism should test Vite's dev server thoroughly with the vite-tsconfig-paths plugin before removing any resolve.alias configuration.
Testing Pipelines During Migration
A frequently overlooked aspect of large Webpack-to-Vite migrations is the impact on the testing pipeline. Jest (still common in large codebases) uses Babel or ts-jest for transformation and has no dependency on Webpack or Vite — Jest tests should continue working unchanged during the migration. However, if your test suite uses Webpack-specific features like require.context for automatic test discovery, or if it imports from process.env.WEBPACK_DEV_SERVER for environment detection, those tests will need updates alongside the main codebase.
Vitest is the natural companion to Vite for testing — it uses the same vite.config.ts configuration and shares the module resolution setup, meaning path aliases, environment variable handling, and plugin transforms work identically in tests and in the app. Migrating from Jest to Vitest is optional during the Webpack-to-Vite migration and is best treated as a separate effort, but teams that migrate both simultaneously report that test reliability improves because test and production code share the same transformation pipeline. For teams keeping Jest, the vitest migration can be deferred while still getting the full Vite dev server speedup — Jest's configuration is independent of the build tool.
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?