modern-react-spa

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 / WebpackWhat breaks in ViteFix
process.env.REACT_APP_*undefined in browserRename to VITE_*; use import.meta.env
import svg from './x.svg' (URL)worksNo change
import { ReactComponent as Icon } from './x.svg'breaksSwitch to vite-plugin-svgr; use ?react query
require('./x.json')mostly worksPrefer import x from './x.json'
require.context('./icons', true, /\.svg$/)breaksUse import.meta.glob('./icons/*.svg')
Absolute imports via jsconfigbreaksAdd vite-tsconfig-paths (Ch 17 §17.5)
Jest testsrun, but slowlyMigrate to Vitest (21.4)
CRACO / customize-cra overridesbreaksRe-implement as Vite plugins / config
CSS Modules with TS typesworks with vite-plugin-css-modules-typesAdd the plugin
Sass / Lessworks with the preprocessornpm install -D sass / less
MDXworks via @mdx-js/rollupAdd the Rollup plugin
Worker importsworks (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 patternVitest fix
jest.mock('./x', () => ({ … }))vi.mock('./x', () => ({ … }))
jest.fn(), jest.spyOn()vi.fn(), vi.spyOn()
jest.useFakeTimers()vi.useFakeTimers()
__mocks__/ directoriesManual vi.mock in setup (no auto-load)
Snapshot filesCompatible; no migration needed
Custom Jest transformersRe-implement as Vite plugins
moduleNameMapper for path aliasesvite-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

  1. Migrating during another major change (React version bump, framework swap) — too many variables.
  2. Skipping the audit — require.context finds you mid-migration with a sinking feeling.
  3. Forgetting import.meta.env.DEV vs process.env.NODE_ENV === 'development' — silent dev-only bugs.
  4. Leaving react-scripts as a transitive devDep — confusing CI.
  5. Vitest tests “globals” vs “imports” — pick one mode and document.
  6. Trying to preserve CRA’s exact webpack-flavored CSS Modules behaviour — Vite’s defaults are usually fine; don’t fight them.
  7. CI cache invalidation surprise — your old CI cached node_modules/.cache/webpack; the new CI caches node_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

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.

Topics in this chapter (5)
  1. 21.1 Pre-flight audit
  2. 21.2 Step-by-step migration script
  3. 21.3 Handling process.env, require, dynamic imports
  4. 21.4 Jest → Vitest migration
  5. 21.5 Real-world case study — 1 400-file SPA, 9 s → 480 ms