Migrating a legacy React app from webpack to Vite
Migrating a Legacy React App from Webpack to Vite
Current Situation Analysis
The legacy codebase suffered from architectural debt accumulated over five years: React 16 with pervasive class components, React Router v3 using routes-as-children, and a heavily patched webpack 4 configuration maintained by a dozen developers. The development loop was critically degraded: cold starts took 45 seconds, HMR cycles ranged from 8 to 20 seconds, and production builds consumed 6 minutes. CI/CD pipelines compounded the bottleneck, resulting in 14-minute end-to-end deploy times.
Webpack's fundamental model requires upfront parsing, compilation, and bundling of the entire dependency graph before serving. This scales linearly with application size, and HMR graph invalidation turns minor code changes into multi-second rebuilds. Traditional optimization strategies (cache tuning, loader splitting, parallel compilation) hit diminishing returns because the bottleneck is architectural, not configurational.
The critical realization is that migrating to Vite is not a standalone tool swap. Vite's performance derives from native ES module serving and on-demand transformation via esbuild, which inherently rejects 2018-era tooling assumptions. Attempting a drop-in replacement fails because Vite enforces modern module resolution, JSX transforms, and dependency optimization. The migration inevitably cascades into upgrading React, rewriting the router, and auditing the dependency tree.
WOW Moment: Key Findings
The performance delta stems from shifting compilation from upfront bundling to on-demand transformation. Production still uses Rollup for optimal tree-shaking, but the dev loop eliminates graph compilation entirely. Secondary CI improvements arise from aggressive tree-shaking (18% bundle reduction), elimination of 800MB persistent cache thrashing, and parallelizable pipeline stages.
| Approach | Dev Cold Start | HMR Latency (Small Change) | Prod Build Time |
|---|---|---|---|
| Webpack 4 | 42s | 4-8s | 5m 40s |
| Vite + esbuild/Rollup | 1.1s | 50-200ms | 1m 50s |
Key Findings:
- Transformation vs. Validation: Vite strips types via esbuild for speed. Static type checking remains unchanged (
tsc --noEmitstill takes ~45s). Vite optimizes transformation, not analysis. - CI Multiplier Effect: Deploy time dropped from 14m to 5m. Only 4m came from build acceleration; the rest came from parallel job execution, cache elimination, and faster Docker layer pushes due to smaller bundles.
- Sweet Spot: Vite delivers maximum ROI when paired with a modernized stack. The performance ceiling is unlocked by aligning tooling with native ESM expectations rather than patching legacy bundler behavior.
Core Solution
The migration follows a phased architectural shift: replace the build orchestrator, restructure the entry point, upgrade core framework dependencies, and modernize routing patterns.
1. Build Tool Configuration
Install the core Vite package and the official React plugin:
npm install --save-dev vite @vitejs/plugin-react
A minimal vite.config.ts establishes aliases, server defaults, and build output settings:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
},
build: {
outDir: 'dist',
sourcemap: true,
},
})
2. Entry Point Restructuring
Webpack treats index.html as a generated template. Vite treats it as the actual entry point. Move index.html to the project root and replace template injection with a direct module script:
<script type="module" src="/src/main.tsx"></script>
3. Framework & Router Modernization
@vitejs/plugin-react requires React 17+ JSX transform. Upgrade to React 18 to leverage Strict Mode and catch legacy effect bugs.
React Router v3's imperative API (browserHistory, onEnter, withRouter) is incompatible with v6's declarative <Routes> model. Migrate routing configuration:
// v3
<Router history={browserHistory}>
<Route path="/" component={App}>
<Route path="users/:id" component={UserPage} onEnter={requireAuth} />
</Route>
</Router>
// v6
<BrowserRouter>
<Routes>
<Route path="/" element={<App />}>
<Route path="users/:id" element={<RequireAuth><UserPage /></RequireAuth>} />
</Route>
</Routes>
</BrowserRouter>
Imperative navigation (browserHistory.push()) and HOC wrappers must be manually refactored to useNavigate and component composition.
4. Dependency & Plugin Audit
Remove legacy Babel plugins for syntax already supported natively by esbuild (optional chaining, nullish coalescing, class properties). Custom transforms must be rewritten as Vite plugins or extracted to separate build steps. Audit pure CommonJS dependencies lacking exports fields; they will conflict with Vite's dep optimizer. Apply patch-package or replace unmaintained packages.
Pitfall Guide
- Assuming Vite Handles Type Checking: Vite strips TypeScript types via esbuild for transformation speed. It does not perform static analysis. You must run
tsc --noEmitas a separate step in CI to maintain type safety. - Treating
index.htmlas a Webpack Template: Vite usesindex.htmlas the actual module entry point. Failing to addtype="module"to script tags or pointing them to the correct TS entry will break the dev server's ESM resolution. - Ignoring the React 17+ JSX Transform Requirement:
@vitejs/plugin-reactrelies on the automatic JSX runtime introduced in React 17. Legacy React 16 setups will fail compilation unless upgraded or pinned to an incompatible legacy plugin version. - Overlooking Imperative Router Patterns in v3→v6 Migration: Vite migration often forces a router upgrade. v3's
browserHistory.push()andwithRouterHOCs break in v6's declarative model. Every imperative navigation call requires manual refactoring touseNavigateand route component composition. - Fighting Vite's Dep Optimizer with Pure CommonJS: Legacy dependencies without an
exportsfield or proper ESM entry points will cause resolution failures. Vite's pre-bundling expects modern module formats. UseoptimizeDeps.includein config, applypatch-package, or replace abandoned packages. - Misconfiguring Environment Variable Exposure: Webpack leaks any
process.envreference. Vite replacesprocess.env.FOOwithimport.meta.env.VITE_FOOand strictly exposes onlyVITE_-prefixed variables to the client. Unprefixed variables are stripped for security, breaking legacy env configurations.
Deliverables
📘 Migration Blueprint A phased execution roadmap for legacy webpack→Vite transitions:
- Phase 1: Config swap & entry point restructuring
- Phase 2: React 18 upgrade & JSX transform alignment
- Phase 3: Router v3→v6 declarative rewrite
- Phase 4: Dependency optimizer audit & Babel plugin removal
- Phase 5: CI pipeline parallelization & cache strategy update
✅ Implementation Checklist
- Install
vite+@vitejs/plugin-reactand remove webpack CLI/loaders - Create
vite.config.tswith React plugin, path aliases, and build sourcemaps - Move
index.htmlto root and convert script tag totype="module" - Upgrade React to 17+ (preferably 18) and enable Strict Mode
- Refactor React Router v3 imperative patterns to v6
<Routes>+useNavigate - Remove redundant Babel plugins; migrate custom transforms to Vite plugins
- Audit CommonJS dependencies; configure
optimizeDepsor apply patches - Replace
process.envwithimport.meta.env.VITE_*and enforce prefix policy - Update CI: split type-check/lint/build into parallel jobs, remove webpack cache restore step
- Validate HMR latency, cold start times, and production bundle size reduction
