Migrating from Create React App + craco to Vite - A reported case study

Last updated Feb 23, 2026 Published Feb 23, 2026

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 Slack

If 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:

  1. Apply a migration step (e.g., update a config, replace a dependency, move a file).
  2. Run the full build (npm run build) to ensure the app still compiled successfully.
  3. Run all Jest tests to verify that unit and integration logic remained correct.
  4. 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:

  1. Make a single migration change
  2. Run:
    • npm run build
    • npm test (Jest)
    • npx cypress run (Cypress E2E)
  3. 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-istanbul is an ESM-only package. Dynamic import() 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' in vite.config.ts. If you do, Vite resolves the app root to public/, and any script path like ../src/index.tsx becomes 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/ to testPathIgnorePatterns. 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:

  1. VITE_COVERAGE=true vitevite-plugin-istanbul instruments files as they are served
  2. Cypress tests run → window.__coverage__ is populated in the browser
  3. @cypress/code-coverage support file harvests window.__coverage__ after each spec and writes to .nyc_output/
  4. npx nyc report --reporter=lcov generates 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/support instead of the package export @cypress/code-coverage/support. The cypress-cucumber-preprocessor uses an old version of the resolve package that doesn’t read package.json exports maps, so the subpath export ./support is 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:

  1. vite-plugin-istanbul is ESM-only → use async defineConfig + dynamic import()
  2. root: 'public' breaks the app → browser can’t resolve ../src/index.tsx above /; use default layout instead
  3. Two index.html files → CRA had one in public/, Vite needs one at the project root; delete public/index.html
  4. eslint: command not found in husky hooks → eslint was transitively installed by react-scripts; add it as a direct devDependency
  5. Unquoted lint glob → zsh silently skips subdirectories; CI catches all errors locally hidden
  6. /cypress/ picked up by Jest → add to testPathIgnorePatterns
  7. @typescript-eslint v7 vs TypeScript 5.x mismatch warning → upgrade to v8
  8. @ts-ignore vs @ts-expect-error@typescript-eslint/recommended prefers @ts-expect-error; update with descriptions
  9. no-var-requires renamed in @typescript-eslint v8 → rule is now no-require-imports
  10. Manual nyc instrument step in CI → delete it; vite-plugin-istanbul replaces it entirely
  11. @cypress/code-coverage/support not found → old resolve package doesn’t read exports map; import dist/lib/support directly
  12. Chromatic port error locally → guard installPlugin with process.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.js as a standalone Jest config.
  • Don’t manually instrument. If you used npx nyc instrument with CRA, throw it away. vite-plugin-istanbul is its direct replacement.
  • Subpath exports are still a minefield. Old tooling like browserify and cypress-cucumber-preprocessor don’t read package.json exports maps — import file paths directly when this bites you.
  • npm overrides is the right tool for forcing patched transitive dependencies when upstream is slow to update.
  • Shell glob quoting matters. Always quote globs in package.json scripts 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.

You also might like