Chapter 21
Migrating from CRA / Webpack to Vite
Migrating a CRA or Webpack 4 SPA to Vite — pre-flight audit, step-by-step migration script, Jest-to-Vitest, and a 1,400-file real-world case study.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can audit a CRA or Webpack 4 SPA for the gotchas, execute a step-by-step migration to Vite without freezing feature work, translate the Jest test suite to Vitest, and quantify the developer-experience improvement to justify the time.
🧭 Prerequisites — Ch 17 (Vite) — required. Ch 36 (testing) for the Jest→Vitest detail.
🔹 21.1 Pre-flight audit
Before you change anything, list the legacy patterns that will fight you.
| Pattern in CRA / Webpack | What breaks in Vite | Fix |
|---|---|---|
process.env.REACT_APP_* | undefined in browser | Rename to VITE_*; use import.meta.env |
import svg from './x.svg' (URL) | works | No change |
import { ReactComponent as Icon } from './x.svg' | breaks | Switch to vite-plugin-svgr; use ?react query |
require('./x.json') | mostly works | Prefer import x from './x.json' |
require.context('./icons', true, /\.svg$/) | breaks | Use import.meta.glob('./icons/*.svg') |
Absolute imports via jsconfig | breaks | Add vite-tsconfig-paths (Ch 17 §17.5) |
| Jest tests | run, but slowly | Migrate to Vitest (21.4) |
| CRACO / customize-cra overrides | breaks | Re-implement as Vite plugins / config |
| CSS Modules with TS types | works with vite-plugin-css-modules-types | Add the plugin |
| Sass / Less | works with the preprocessor | npm install -D sass / less |
| MDX | works via @mdx-js/rollup | Add the Rollup plugin |
| Worker imports | works (new Worker(new URL('./w.ts', import.meta.url))) | Update syntax |
Run a grep for each legacy pattern. Quantify the work before you start.
grep -rn "process.env.REACT_APP" src/ # how many to rename
grep -rn "ReactComponent" src/ # how many SVG-as-React imports
grep -rn "require\.context" src/ # the painful one
grep -rn "from 'react-scripts'" src/ # CRA-specific imports
If require.context appears in dozens of files, plan extra time. The rest are usually mechanical.
🔹 21.2 Step-by-step migration script
A 6-step PR-able recipe. Each step is reviewable in isolation.
Step 1 — Add Vite alongside CRA
npm install -D vite @vitejs/plugin-react vite-tsconfig-paths
📄 vite.config.ts at the repo root
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
server: { port: 3000, open: true },
});
📄 index.html (move from public/index.html to root)
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Acme</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script> <!-- ← entry script -->
</body>
</html>
CRA’s public/index.html had %PUBLIC_URL% placeholders — replace with absolute paths or Vite-compatible URLs.
Add a script: "dev:vite": "vite". Run it. Most apps render at this point with a few errors in the console.
Step 2 — Rename env vars
A Node codemod — runs on macOS, Linux, Windows, and CI without sed/grep portability headaches:
📄 scripts/migrate-env.mjs
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs';
const files = execSync('git ls-files', { encoding: 'utf-8' })
.split('\n')
.filter((f) => /\.(t|j)sx?$/.test(f));
for (const file of files) {
const before = readFileSync(file, 'utf-8');
const after = before
.replace(/process\.env\.REACT_APP_/g, 'import.meta.env.VITE_')
.replace(/process\.env\.NODE_ENV/g, 'import.meta.env.MODE');
if (after !== before) writeFileSync(file, after);
}
if (existsSync('.env')) renameSync('.env', '.env.local');
console.log(`✓ rewrote ${files.length} files; renamed .env → .env.local`);
Run: node scripts/migrate-env.mjs. Then sanity-check with a search for any leftover process.env. references in src/ (your IDE’s project-wide search, or grep -rn/Select-String if you prefer the shell).
⚠️ This is the diff that affects every developer. Land it in a focused PR with a clear “rebase yours” instruction.
Step 3 — Replace SVG-as-React
npm install -D vite-plugin-svgr
Update vite.config.ts:
import svgr from 'vite-plugin-svgr';
plugins: [react(), tsconfigPaths(), svgr()],
Then a codemod (or sed) for the import shape:
- import { ReactComponent as Icon } from './icon.svg';
+ import Icon from './icon.svg?react';
Step 4 — Replace require.context
- const ctx = require.context('./icons', true, /\.svg$/);
- const icons = ctx.keys().reduce((acc, key) => { acc[key.replace(/^\.\/|\.svg$/g, '')] = ctx(key); return acc; }, {});
+ const modules = import.meta.glob('./icons/*.svg', { eager: true, query: '?react', import: 'default' });
+ const icons = Object.fromEntries(Object.entries(modules).map(([k, v]) => [
+ k.replace(/^\.\/|\.svg$/g, ''), v as React.FC<React.SVGProps<SVGSVGElement>>,
+ ]));
import.meta.glob is Vite’s equivalent. The eager: true option inlines the modules; without it you get lazy-loaded.
Step 5 — Delete CRA
npm uninstall react-scripts @types/react-scripts
rm -rf public/index.html # already moved to root
Update package.json scripts:
- "start": "react-scripts start",
- "build": "react-scripts build",
- "test": "react-scripts test",
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "test": "vitest"
Step 6 — Verify
npm install
npm run dev # SPA renders, HMR works
npm run build # produces dist/ with route-split chunks
npm run preview # serves dist/ for sanity
npm run test # tests pass
If dist/ looks reasonable and tests pass, ship it.
🔹 21.3 Handling process.env, require, dynamic imports
Three concrete patterns and their fixes:
process.env.X in browser code
- if (process.env.NODE_ENV === 'production') { /* … */ }
+ if (import.meta.env.PROD) { /* … */ }
Vite exposes import.meta.env.MODE, .DEV, .PROD. Prefer these for environment checks. process.env.NODE_ENV is shimmed for compatibility but the other process.env.* keys are not.
require('./x.json')
- const config = require('./config.json');
+ import config from './config.json';
Or for dynamic data:
- const configs = require.context('./tenants', false, /\.json$/);
+ const configs = import.meta.glob('./tenants/*.json', { eager: true });
Dynamic CSS imports
- if (theme === 'dark') require('./dark.css');
+ if (theme === 'dark') await import('./dark.css');
require is CJS; Vite is ESM-first. await import() is the modern equivalent.
🔹 21.4 Jest → Vitest migration
Vitest is API-compatible with Jest in 95 % of cases. The remaining 5 % is where you’ll spend most of the migration time.
npm install -D vitest @testing-library/react @testing-library/jest-dom happy-dom
npm uninstall jest @types/jest jest-environment-jsdom react-scripts
📄 vite.config.ts — add Vitest config
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: 'happy-dom', // ~3× faster than jsdom; switch if you hit limits
globals: true, // describe/it/expect available without imports
setupFiles: ['./vitest.setup.ts'],
},
});
📄 vitest.setup.ts
import '@testing-library/jest-dom/vitest';
The 5 % that fights you:
| Jest pattern | Vitest fix |
|---|---|
jest.mock('./x', () => ({ … })) | vi.mock('./x', () => ({ … })) |
jest.fn(), jest.spyOn() | vi.fn(), vi.spyOn() |
jest.useFakeTimers() | vi.useFakeTimers() |
__mocks__/ directories | Manual vi.mock in setup (no auto-load) |
| Snapshot files | Compatible; no migration needed |
| Custom Jest transformers | Re-implement as Vite plugins |
moduleNameMapper for path aliases | vite-tsconfig-paths handles it |
A codemod (jest-to-vitest) handles ~80 % mechanically. Hand-fix the rest.
For the full testing pyramid, see Ch 36.
🔹 21.5 Real-world case study — 1 400-file SPA, 9 s → 480 ms
A team’s CRA 5 SPA. Before migration:
npm start cold: 9.2 s
npm start HMR: ~1.5 s on every edit
npm run build prod: 98 s
npm test cold: 22 s
After migration (Vite + Vitest, no other changes):
npm run dev cold: 480 ms (~19× faster)
npm run dev HMR: ~80 ms (~19× faster)
npm run build prod: 41 s (~2.4× faster)
npm test cold: 6.8 s (~3.2× faster)
What it took: 3 weeks of one developer’s time, mostly the require.context swaps (the codebase had a dynamic icon-loader). The team-wide rebase day cost about 4 hours.
What was gained:
- 8.7 s saved per cold start × ~30 starts per developer per day × 12 developers ≈ 50 minutes per developer per day on dev-server only.
- 1.4 s saved per HMR × ~100 saves per developer per day × 12 developers ≈ 28 minutes per developer per day on HMR.
- 57 s saved per CI build × ~80 PRs per week ≈ 76 minutes per week of CI compute.
Payback period: ~6 weeks measured by developer time alone. Worth doing.
🪤 Common Pitfalls
- Migrating during another major change (React version bump, framework swap) — too many variables.
- Skipping the audit —
require.contextfinds you mid-migration with a sinking feeling. - Forgetting
import.meta.env.DEVvsprocess.env.NODE_ENV === 'development'— silent dev-only bugs. - Leaving
react-scriptsas a transitive devDep — confusing CI. - Vitest tests “globals” vs “imports” — pick one mode and document.
- Trying to preserve CRA’s exact webpack-flavored CSS Modules behaviour — Vite’s defaults are usually fine; don’t fight them.
- CI cache invalidation surprise — your old CI cached
node_modules/.cache/webpack; the new CI cachesnode_modules/.vite.
✅ Recap
- Audit before migrating; pattern-grep finds 80 % of the work.
- Migrate in 6 steps, each a reviewable PR.
- Jest → Vitest is 95 % mechanical; codemod helps.
- The DX delta is real and measurable; quantify to justify the time.
🔗 Further Reading
- https://vitejs.dev/guide/migration.html — official Vite migration guide.
- https://github.com/anonyco/jest-to-vitest — codemod for the test suite.
- Ch 17 (Vite deep dive), Ch 36 (testing pyramid).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.