Table of contents
- Why Migrate?
- What the Stack Looked Like Before
- The process
- Phase 1: Replace the Build Tool
- Phase 2: Keep Jest (Don’t Migrate to Vitest)
- Phase 3: Modernise ESLint
- Phase 4: Fix Tailwind and Remove babel-plugin-preval
- Phase 5: Replace @cypress/instrument-cra with vite-plugin-istanbul
- Phase 6: Handle Chromatic
- Phase 7: Fix npm Audit Vulnerabilities
- The Full Gotcha List
- End Result
- Key Takeaways
- Benefits so far
Migrating from Create React App + craco to Vite - A reported case study
The content here is under the Attribution 4.0 International (CC BY 4.0) license
Join Our Community
Connect with developers, architects, and tech leads who share your passion for quality software development. Discuss TDD, architecture, software engineering, and more.
→ Join SlackIf you’ve been maintaining a React app bootstrapped with Create React App (CRA) and patching its locked webpack config with craco, you’ve probably felt the friction. Slow cold starts, cryptic dependency conflicts, and the growing realization that CRA has been effectively abandoned since 2023.
This post documents the full migration of json-tool, a React 19 + TypeScript app, from CRA + craco to Vite, while keeping Jest (not switching to Vitest at the moment), preserving Cypress E2E coverage, and fixing every rough edge that showed up in CI along the way.
JSON tool?
JSON tool is a free online JSON formatter and validator built with React and TypeScript. You can find more details about it at:
Why Migrate?
Before diving in, here’s why it’s worth the effort:
| CRA + craco | Vite | |
|---|---|---|
| Dev server cold start | Webpack bundles everything upfront | esbuild pre-bundles deps once; ESM for source files |
| HMR | Full incremental rebuild | Only the changed module, always fast |
| Build tool | webpack (JS, slow) | Rollup (fast, well-optimised output) |
| Config | Hidden webpack config, craco patches it | Explicit vite.config.ts, ~30 lines |
| Maintenance | CRA abandoned ~2023 | Actively maintained |
| TypeScript | Babel transpiles | esbuild transpiles natively |
craco existed solely as a workaround to customise CRA’s locked webpack config without ejecting. Removing it means one fewer layer of monkey-patching on top of a tool that’s no longer maintained.
What the Stack Looked Like Before
react-scripts (CRA)
craco
@craco/craco
babel-plugin-preval ← used in tailwindResolver.js
eslint-config-react-app ← transitive, pulled in eslint + typescript
@cypress/instrument-cra ← CRA-specific Istanbul instrumentation for Cypress coverage
The process
The migration was performed incrementally. The app already had a comprehensive test suite: unit and integration tests in Jest, and end-to-end tests in Cypress. This allowed for a robust workflow:
- Apply a migration step (e.g., update a config, replace a dependency, move a file).
-
Run the full build (
npm run build) to ensure the app still compiled successfully. - Run all Jest tests to verify that unit and integration logic remained correct.
- Run all Cypress E2E tests to confirm the app worked as expected in the browser.
This cycle was repeated for each migration change, one at a time. If any step failed, the change was reviewed and fixed before proceeding. This disciplined approach ensured that the migration never broke the app, and regressions were caught immediately. In summary, the exact flow for each migration step was:
- Make a single migration change
- Run:
npm run build-
npm test(Jest) -
npx cypress run(Cypress E2E)
- Only proceed if all checks passed
The steps mentioned were run in docker containers to ensure a consistent environment, matching CI as closely as possible. The repository contains a script named before-push.sh that automates this entire workflow locally before pushing to GitHub.
A plan was drafted beforehand using Claude, it was asked to analyze the repository and create a step-by-step migration plan, with the following prompt:
prepare a plan with effort to migrate the setup used in this project with craco by one that is more modern with vite
Phase 1: Replace the Build Tool
Install Vite and its React plugin
npm install --save-dev vite @vitejs/plugin-react
npm uninstall @craco/craco react-scripts
Create vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(async () => {
const plugins: import('vite').PluginOption[] = [react()];
// Istanbul instrumentation for Cypress E2E coverage
// Only active when VITE_COVERAGE=true (instrumented dev server)
if (process.env.VITE_COVERAGE === 'true') {
const { default: istanbul } = await import('vite-plugin-istanbul');
plugins.push(
istanbul({
include: 'src/**',
exclude: ['node_modules', 'src/__test__/**'],
extension: ['.js', '.ts', '.tsx'],
requireEnv: false,
cypress: true,
})
);
}
return {
plugins,
base: './',
build: {
outDir: 'build',
sourcemap: true,
},
server: {
host: '0.0.0.0',
port: 3000,
},
};
});
Why
async defineConfig?vite-plugin-istanbulis an ESM-only package. Dynamicimport()is the only way to load it inside a config file without triggering ESM/CJS interop errors.
Move index.html to the project root
Vite expects index.html at the project root, not inside public/. Replace %PUBLIC_URL% tokens and point the script tag at the source entry:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>JSON Tool</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
Static assets (favicon, manifest.json, logos) stay in public/ — Vite serves them automatically via publicDir: 'public' (the default).
Delete public/index.html — having two index.html files causes confusion.
Gotcha: Don’t set
root: 'public'invite.config.ts. If you do, Vite resolves the app root topublic/, and any script path like../src/index.tsxbecomes unreachable in the browser (can’t traverse above/).
Update package.json scripts
"scripts": {
"start": "vite",
"build": "vite build",
"start-instrumented": "VITE_COVERAGE=true vite"
}
Update src/react-app-env.d.ts
// Before
/// <reference types="react-scripts" />
// After
/// <reference types="vite/client" />
Phase 2: Keep Jest (Don’t Migrate to Vitest)
Vite uses Rollup/esbuild at build time and native ESM in dev, but Jest is a Node.js test runner — it doesn’t use Vite at all. You need a standalone Babel config so Jest can still transpile TypeScript and JSX.
Install Babel presets
npm install --save-dev @babel/preset-env @babel/preset-react @babel/preset-typescript babel-jest
Create babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' }, modules: 'commonjs' }],
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript',
],
};
Why
modules: 'commonjs'? Jest runs in Node.js and needs CommonJS. Without this, any ESM import in your source will crash Jest.
Configure Jest in package.json
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/src/setupTests.ts"],
"testPathIgnorePatterns": ["/node_modules/", "/cypress/"],
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "<rootDir>/src/__mocks__/styleMock.js"
},
"transform": {
"^.+\\.(tsx?|js)$": "babel-jest"
}
}
Create src/__mocks__/styleMock.js:
module.exports = {};
Gotcha: Add
/cypress/totestPathIgnorePatterns. Otherwise Jest picks up Cypress spec files and tries to run them, failing immediately.
Phase 3: Modernise ESLint
With react-scripts gone, eslint and typescript are no longer available as transitive dependencies. Install them directly:
npm install --save-dev eslint@^8.57.1 typescript@^5.8.2 \
@typescript-eslint/eslint-plugin@^8 \
@typescript-eslint/parser@^8 \
eslint-plugin-react-hooks \
eslint-plugin-react
Remove eslint-config-react-app — it was the CRA-specific ESLint config.
Update eslintConfig in package.json:
"eslintConfig": {
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"plugin:cypress/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "react-hooks"],
"settings": {
"react": { "version": "detect" }
}
}
Quote the lint glob
This one is subtle. An unquoted glob in package.json scripts is expanded by the shell before being passed to eslint:
// BAD — zsh on macOS doesn't recurse ** without globstar enabled
"lint": "eslint src/**/*.{ts,tsx}"
// GOOD — eslint receives the literal glob string and handles expansion itself
"lint": "eslint 'src/**/*.{ts,tsx}'"
On macOS with zsh, the unquoted version silently only lints src/*.ts(x) — all nested files are skipped. CI (Linux/bash) sends the literal glob to eslint and catches everything. This led to 14 lint errors appearing in CI that were invisible locally.
Phase 4: Fix Tailwind and Remove babel-plugin-preval
CRA used babel-plugin-preval to evaluate tailwindResolver.js at build time. Vite doesn’t use Babel at build time, so preval doesn’t work. Remove it:
npm uninstall babel-plugin-preval
Convert src/tailwindResolver.js from CJS+preval to plain ESM:
// Before
const resolveConfig = require('tailwindcss/resolveConfig');
const tailwindConfig = require('../tailwind.config.js');
module.exports = /* preval */ resolveConfig(tailwindConfig);
// After
import resolveConfig from 'tailwindcss/resolveConfig';
import tailwindConfig from '../tailwind.config.js';
export default resolveConfig(tailwindConfig);
Convert tailwind.config.js to ESM too (required because the above imports it):
// Before
module.exports = { content: [...], theme: {...} }
// After
export default { content: [...], theme: {...} }
Update content paths in tailwind.config.js to include the root index.html:
content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html']
Phase 5: Replace @cypress/instrument-cra with vite-plugin-istanbul
The old CI pipeline manually instrumented source files:
# OLD — CRA/webpack specific, breaks Vite
- name: instrument application
run: |
npx nyc instrument src/ instrumented
cp -R instrumented/ src/
rm -rf instrumented
With Vite, vite-plugin-istanbul handles instrumentation transparently at dev-server startup. Delete the entire “instrument application” step from CI.
Start the instrumented server with:
VITE_COVERAGE=true vite
The coverage collection flow becomes:
-
VITE_COVERAGE=true vite→vite-plugin-istanbulinstruments files as they are served - Cypress tests run →
window.__coverage__is populated in the browser -
@cypress/code-coveragesupport file harvestswindow.__coverage__after each spec and writes to.nyc_output/ -
npx nyc report --reporter=lcovgenerates the lcov report
Restore the support file import
Make sure cypress/support/e2e.js imports the coverage support:
import '@cypress/code-coverage/dist/lib/support';
Note: Use the direct file path
dist/lib/supportinstead of the package export@cypress/code-coverage/support. Thecypress-cucumber-preprocessoruses an old version of theresolvepackage that doesn’t readpackage.jsonexports maps, so the subpath export./supportis invisible to it.
Phase 6: Handle Chromatic
@chromatic-com/cypress requires a remote debugging port. Locally this causes an error when you run cypress open without the right flag:
Please provide a port number
Example: ELECTRON_EXTRA_LAUNCH_ARGS=--remote-debugging-port=<port-number> yarn cypress run
The solution: conditionally call installPlugin only when running in CI. GitHub Actions sets CI=true automatically, so no extra env var is needed.
// cypress.config.js
setupNodeEvents(on, config) {
coverage(on, config);
on('file:preprocessor', cucumber());
if (process.env.ELECTRON_EXTRA_LAUNCH_ARGS) {
on('task', {
prepareArchives: () => {
// Your task logic here (e.g., file operations, cleanup, etc.)
console.log('Preparing archives...');
return null; // or return a value if needed
},
});
}
// Only register Chromatic plugin in CI — locally it throws without a debugging port
if (process.env.CI) {
installPlugin(on, config);
}
return config;
}
| Environment |
CI set? |
installPlugin runs? |
|---|---|---|
| GitHub Actions | ✅ (automatic) | ✅ archives generated for Chromatic |
Local npm run e2e
|
✅ | uses prepareArchives |
Local cypress open
|
✅ | uses prepareArchives |
Phase 7: Fix npm Audit Vulnerabilities
After migration, @chromatic-com/cypress pulled in old storybook/webpack internals with 17 vulnerabilities (13 high, 4 low). Use npm overrides to force patched versions without waiting for upstream updates:
"overrides": {
"elliptic": "6.6.1",
"serialize-javascript": "7.0.3",
"storybook": "8.6.17",
"@storybook/builder-webpack5": "8.6.17",
"@storybook/server-webpack5": "8.6.17"
}
This reduced the audit from 17 vulnerabilities (13 high) to 4 low — the remaining elliptic ones have no upstream fix yet, as the CVE covers all versions.
The Full Gotcha List
Here’s every non-obvious problem encountered, condensed:
-
vite-plugin-istanbulis ESM-only → useasync defineConfig+ dynamicimport() -
root: 'public'breaks the app → browser can’t resolve../src/index.tsxabove/; use default layout instead -
Two
index.htmlfiles → CRA had one inpublic/, Vite needs one at the project root; deletepublic/index.html -
eslint: command not foundin husky hooks →eslintwas transitively installed byreact-scripts; add it as a direct devDependency - Unquoted lint glob → zsh silently skips subdirectories; CI catches all errors locally hidden
-
/cypress/picked up by Jest → add totestPathIgnorePatterns -
@typescript-eslintv7 vs TypeScript 5.x mismatch warning → upgrade to v8 -
@ts-ignorevs@ts-expect-error→@typescript-eslint/recommendedprefers@ts-expect-error; update with descriptions -
no-var-requiresrenamed in@typescript-eslintv8 → rule is nowno-require-imports -
Manual
nyc instrumentstep in CI → delete it;vite-plugin-istanbulreplaces it entirely -
@cypress/code-coverage/supportnot found → oldresolvepackage doesn’t readexportsmap; importdist/lib/supportdirectly -
Chromatic port error locally → guard
installPluginwithprocess.env.CHROMATIC
End Result
vite build — 154 modules, 1.63s
jest — 103/103 tests, 16 suites
eslint — 0 errors, all nested files checked
cypress — E2E runs locally and in CI
coverage — window.__coverage__ → .nyc_output → lcov
chromatic — visual snapshots generated in CI
npm audit — 4 low (no upstream fix available), 0 high
Key Takeaways
-
Vite and Jest are independent. Vite doesn’t change how Jest works at all. Keep
babel.config.jsas a standalone Jest config. -
Don’t manually instrument. If you used
npx nyc instrumentwith CRA, throw it away.vite-plugin-istanbulis its direct replacement. -
Subpath exports are still a minefield. Old tooling like
browserifyandcypress-cucumber-preprocessordon’t readpackage.jsonexports maps — import file paths directly when this bites you. -
npm
overridesis the right tool for forcing patched transitive dependencies when upstream is slow to update. -
Shell glob quoting matters. Always quote globs in
package.jsonscripts so the tool (eslint, etc.) receives and expands them — not your local shell.
Benefits so far
Faster Build Times
After migrating to Vite, the build process became significantly faster. Vite leverages modern tooling (esbuild and Rollup) to pre-bundle dependencies and only rebuild what changes, resulting in much shorter build and development server startup times. This improvement is especially noticeable in larger projects, where waiting for webpack to finish was a daily pain point. Now, both local development and CI pipelines complete builds in a fraction of the previous time, boosting productivity and feedback cycles.
Improved Security Posture
Before the migration, the project had accumulated 17 high-severity security issues in its dependencies—many of which were due to outdated or abandoned packages in the CRA/craco ecosystem. The migration to Vite provided an opportunity to review, update, and in many cases remove unnecessary dependencies. As a result, the dependency tree is now smaller, more modern, and security updates are easier to track and apply. Automated tools (like npm audit and GitHub Dependabot) now report a clean bill of health, and the project is no longer blocked by unmaintained packages.