---
name:        cra-to-vite
description: "Migrates a Create React App project to Vite 5.x + Vitest using a strangler-fig approach: both build tools run in parallel until Vite is proven equivalent, then CRA is removed."
metadata:
  phase:           4
  source_stack:    "Create React App (react-scripts 5.x), Webpack 5, REACT_APP_ env vars, Jest + src/setupTests.ts"
  target_stack:    "Vite 5.x, @vitejs/plugin-react, VITE_ env vars, Vitest 1.x"
  effort_estimate: L
  last_updated:    2026-04-04
---

# 1. Purpose

This skill replaces a Create React App setup (react-scripts, Webpack 5) with Vite 5.x and
Vitest using a strangler-fig approach: Vite is introduced alongside the existing CRA toolchain,
both build pipelines run in CI simultaneously, and traffic is switched via a feature flag once
Vite output is proven equivalent. A frontend engineer or build-systems engineer runs this skill
once per application — it is not file-by-file; it operates at the toolchain level. The migration
is non-trivial because CRA abstracts Webpack configuration that Vite does not replicate
automatically: environment variable prefixes change (`REACT_APP_` → `VITE_`), absolute import
resolution must be re-expressed as Vite aliases, CSS Modules defaults differ in edge cases, and
the Jest test suite must be ported to Vitest, whose API is compatible but whose module mocking
semantics diverge in specific patterns. The rollback path keeps `react-scripts` in `package.json`
and the original CI build script intact throughout the transition; Vite is only promoted to
primary when all equivalence gates pass in staging.

---

# 2. Trigger Conditions

**Use when:**
- The application's build tooling is `react-scripts` (CRA), confirmed by `"react-scripts"` in `package.json` `scripts.build`.
- The Phase 3 preparation is complete: the repo has a staging environment, a CI pipeline that can run two build scripts in parallel, and a feature flag system that can toggle which build artifact is served.
- The full Jest suite passes on the current commit (`npm test -- --watchAll=false` exits 0).
- The application has no CRA `eject` history — `package.json` must not contain a `"webpack"` direct dependency or a `config/webpack.config.js` file. Ejected CRAs require a different skill.
- A Lighthouse baseline for the staging URL has been recorded (needed for the regression gate).

**Do NOT use when:**
- The application has been ejected from CRA (`config/webpack.config.js` exists) — the Webpack config must be ported manually before this skill applies.
- The application uses `react-app-rewired` or `craco` — custom Webpack overrides need to be inventoried and mapped to Vite plugin equivalents before running this skill.
- The Jest suite is currently failing — do not migrate the test runner on top of a broken suite.
- The team is mid-way through a TypeScript migration (`skills/04-migrate/frontend/js-to-typescript/`) — finish the TS migration first; mixed `.js`/`.ts` files in CRA behave differently under Vite's default config.
- There is no staging environment — the Lighthouse regression gate cannot be validated without one.

---

# 3. Inputs

**Required:**

| Input | Type | Description |
|-------|------|-------------|
| `repo_root` | file-path | Absolute path to the repository root (where `package.json` lives). All commands run from here unless noted. |
| `app_name` | string | Value of `name` in `package.json`. Used to name output artifacts and identify the correct `package.json` when the repo is a monorepo. |
| `staging_url` | string | Full URL of the staging environment (e.g. `https://staging.example.com`). Used to run Lighthouse before and after the Vite build is wired in. |
| `lighthouse_baseline` | file-path | Path to a Lighthouse JSON report captured before this skill runs. Generate with `npx lighthouse <staging_url> --output json --output-path <path>`. If absent, halt and ask the user to generate it. |
| `feature_flag_key` | string | The feature flag key (in the team's flag system) that controls which build artifact CI serves to staging. The flag must exist and default to `false` (CRA artifact) before this skill runs. |

**Optional:**

| Input | Type | Default | Description |
|-------|------|---------|-------------|
| `vite_port` | integer | `3001` | Local dev port for the Vite dev server. CRA defaults to `3000`; use a different port so both can run simultaneously during parallel validation. |
| `lighthouse_budget_points` | integer | `5` | Maximum allowed Lighthouse score regression (Performance category) before the equivalence gate fails. Raise to `10` only with explicit sign-off; `>10` is the hard rollback trigger. |
| `css_modules_local_ident` | string | `[local]_[hash:5]` | CSS Modules `generateScopedName` pattern for Vite. CRA uses `[name]__[local]--[hash:base64:5]`; set this to match if server-rendered class names are hardcoded anywhere. |
| `vitest_setup_file` | file-path | `src/setupTests.ts` | Path to the test setup file to pass to Vitest's `setupFiles`. Maps directly from CRA's `setupTests` convention. |

<!--
  STOP CONDITIONS:
  - If `package.json` does not contain `"react-scripts"` in `scripts.build`, halt:
    "This skill requires a CRA project. Found <actual build command> instead of react-scripts."
  - If `lighthouse_baseline` file does not exist at the given path, halt:
    "I need a Lighthouse baseline JSON at <lighthouse_baseline>. Run:
     npx lighthouse <staging_url> --output json --output-path <path>"
  - If `feature_flag_key` is not provided, halt:
    "I need a feature flag key to control which build artifact staging serves. Provide it or
     confirm a flag has been created before running this skill."
-->

---

# 4. Steps

1. Read `<repo_root>/package.json`. Confirm `scripts.build` contains `react-scripts build`. Record the current versions of `react-scripts`, `react`, and `react-dom` as `cra_react_version`. Confirm no `config/webpack.config.js` exists — if it does, STOP: "This project has been ejected from CRA. This skill does not apply."

2. Read every file matching `<repo_root>/src/**/*.{js,ts,jsx,tsx}` and grep for `process.env.REACT_APP_`. Collect each unique variable name (e.g. `REACT_APP_API_URL`, `REACT_APP_FEATURE_X`). Write the full list to `output/cra-to-vite-env-map-<timestamp>.json` as an array of `{ "old": "REACT_APP_FOO", "new": "VITE_FOO" }` objects.
   - Also grep for `process.env.NODE_ENV` — these do not need renaming but must be confirmed to work under Vite (Vite exposes `import.meta.env.MODE`, not `process.env.NODE_ENV`). Flag each occurrence for Step 10.

3. Read `<repo_root>/tsconfig.json` (or `jsconfig.json`). Extract every path alias under `compilerOptions.paths` (e.g. `"@components/*": ["src/components/*"]`). Write the alias list to `output/cra-to-vite-alias-map-<timestamp>.json` as `{ "find": "@components", "replacement": "/src/components" }` objects. These become `resolve.alias` entries in `vite.config.ts`.

4. → Hand off to `code-archaeologist` (see Section 5. Agent Handoffs) to inventory CSS Modules usage patterns. Wait for `output/cra-to-vite-css-inventory-<timestamp>.md` before continuing.

5. Install Vite and its dependencies without removing `react-scripts`. Run from `<repo_root>`:
   ```bash
   npm install --save-dev \
     vite@^5 \
     @vitejs/plugin-react@^4 \
     vitest@^1 \
     @vitest/ui@^1 \
     @testing-library/jest-dom@^6 \
     jsdom@^24
   ```
   - If this fails: check for peer dependency conflicts with `react@<cra_react_version>`. The most common conflict is `@testing-library/react` version pinned by CRA. Resolve by adding `--legacy-peer-deps` and log the override in the migration log.

6. Create `<repo_root>/vite.config.ts` with the following content, substituting the alias list from Step 3 and the port from `vite_port`:

   ```typescript
   import { defineConfig } from 'vite';
   import react from '@vitejs/plugin-react';
   import { resolve } from 'path';

   // Aliases derived from tsconfig.json compilerOptions.paths — keep in sync.
   // Generated by skills/04-migrate/frontend/cra-to-vite on <timestamp>.
   const aliases = [
     // <<INSERT alias objects from output/cra-to-vite-alias-map-<timestamp>.json>>
     // Example: { find: '@components', replacement: resolve(__dirname, 'src/components') }
   ];

   export default defineConfig(({ mode }) => ({
     plugins: [react()],

     resolve: {
       alias: aliases,
     },

     server: {
       port: <vite_port>,       // CRA uses 3000; keep Vite on a different port during parallel run.
       open: false,             // Don't auto-open browser — CI environments will fail.
       strictPort: true,        // Fail fast if the port is taken rather than silently rebinding.
     },

     build: {
       outDir: 'build-vite',   // Deliberately NOT 'build' — CRA still owns 'build/' during transition.
       sourcemap: true,
       rollupOptions: {
         output: {
           // Stable chunk names for diffing against CRA output.
           entryFileNames: 'static/js/[name].[hash].js',
           chunkFileNames: 'static/js/[name].[hash].js',
           assetFileNames: 'static/media/[name].[hash][extname]',
         },
       },
     },

     css: {
       modules: {
         // CRA default: [name]__[local]--[hash:base64:5]
         // Adjust generateScopedName to match if class names are referenced in tests or snapshots.
         generateScopedName: '<css_modules_local_ident>',
       },
     },

     test: {
       globals: true,           // Allows `describe`, `it`, `expect` without imports — matches Jest API.
       environment: 'jsdom',
       setupFiles: ['<vitest_setup_file>'],
       css: true,               // Process CSS Modules in tests — CRA's Jest config does this via identity-obj-proxy.
       coverage: {
         provider: 'v8',
         reporter: ['text', 'lcov'],
       },
     },

     // Vite does not inject process.env. Replace REACT_APP_ vars and NODE_ENV shims.
     define: {
       // <<INSERT: one entry per REACT_APP_ variable found in Step 2, using import.meta.env>>
       // Example: 'process.env.REACT_APP_API_URL': 'import.meta.env.VITE_API_URL',
       // NODE_ENV shim — remove once all process.env.NODE_ENV references are updated to import.meta.env.MODE.
       'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'),
     },
   }));
   ```
   - After writing the file, run `npx tsc --noEmit -p tsconfig.json` to confirm the config parses without errors. If it fails, check that `"moduleResolution": "bundler"` or `"node"` is set in tsconfig and that `vite/client` types are available.

7. Create `<repo_root>/index.html` in the repo root (Vite requires the HTML entry point at root, not `public/index.html`). Copy `public/index.html`, then:
   - Remove the `%PUBLIC_URL%` prefix from all asset paths (Vite serves from root; `%PUBLIC_URL%` is a CRA-ism).
   - Add `<script type="module" src="/src/index.tsx"></script>` (or `.jsx`/`.js` — match the actual entry file) before `</body>`.
   - Confirm `<link rel="icon">` and `<link rel="manifest">` still reference valid paths under `public/`.
   - If this fails: check that `public/index.html` exists and that the entry file is `src/index.tsx` (or equivalent). Log the actual entry file path used.

8. Add Vite scripts to `package.json` without removing the CRA scripts. The CRA scripts remain untouched until Section 9 gates pass:
   ```json
   {
     "scripts": {
       "start":       "react-scripts start",
       "build":       "react-scripts build",
       "test":        "react-scripts test --watchAll=false",
       "start:vite":  "vite --port <vite_port>",
       "build:vite":  "vite build",
       "test:vite":   "vitest run",
       "preview:vite":"vite preview --port <vite_port>"
     }
   }
   ```

9. Rename all environment variables in source files: for each entry in `output/cra-to-vite-env-map-<timestamp>.json`, replace `process.env.<OLD>` with `import.meta.env.<NEW>` across all files in `<repo_root>/src/`. Write each replacement to the migration log (file path, line number, old → new).
   - Also update any `.env`, `.env.development`, `.env.production`, `.env.test` files: rename each `REACT_APP_FOO=value` line to `VITE_FOO=value`.
   - Flag any `process.env.NODE_ENV` occurrences found in Step 2 with an inline comment: `/* TODO(cra-to-vite): replace with import.meta.env.MODE */`. Do not replace them automatically — MODE and NODE_ENV have slightly different value sets.

10. Address CSS Modules incompatibilities surfaced in the inventory from Step 4:
    - If the inventory reports any `:local()` or `:global()` pseudo-selectors: confirm Vite's CSS Modules implementation handles them identically. Log any divergence.
    - If any test file imports a `.module.css` and asserts on class name strings (e.g. `expect(el.className).toBe('Button__root--abc12')`): update the expected string to match `css_modules_local_ident` pattern, or convert the assertion to `toHaveClass` which is class-name-format-agnostic.

11. Port the Jest test configuration to Vitest. Read `<repo_root>/package.json` `jest` key (or `jest.config.js` if present). For each Jest configuration field, apply the Vitest equivalent:
    - `moduleNameMapper` → `resolve.alias` in `vite.config.ts` (already handled for path aliases in Step 6); CSS module mappers (`identity-obj-proxy`) are replaced by `css: true` in the Vitest config.
    - `transform` → handled by Vite's plugin pipeline; remove unless there is a non-standard transform.
    - `setupFilesAfterFramework` / `setupFiles` → already mapped to `vitest_setup_file` in `vite.config.ts`.
    - `testEnvironment: 'jsdom'` → already set in `vite.config.ts`.
    - `globals` mock patterns using `jest.mock(...)`: audit for patterns that use `jest.fn()` or `jest.spyOn()` — these work unchanged in Vitest with `globals: true`. Patterns that use `jest.resetModules()` or `jest.isolateModules()` require `vi.resetModules()` / `vi.isolateModules()` — find and replace.
    - Write a diff of every Jest→Vitest config change to the migration log.

12. Run the Vitest suite: `npx vitest run` from `<repo_root>`. Record full output and exit code as `vitest_result`.
    - If exit code is non-zero: read the failure output. For each failing test:
      - If failure is `vi.fn is not a function` or similar: the test uses a Jest global not shimmed — add `import { vi } from 'vitest'` at the top of that test file.
      - If failure is a CSS class name assertion mismatch: update the assertion per Step 10 guidance.
      - If failure is a module resolution error (`Cannot find module '@/...'`): the alias from Step 3 is missing or malformed in `vite.config.ts` — re-read the alias map and fix.
      - Fix the minimum required to make the suite pass. Do not refactor test logic.
    - Do not proceed until `vitest_result` exit code is 0.

13. Run the Vite build: `npm run build:vite` from `<repo_root>`. Confirm it exits 0 and writes artifacts to `build-vite/`.
    - If this fails: read the Rollup error. Common causes:
      - `require is not defined` — a CommonJS dependency is not pre-bundled; add it to `optimizeDeps.include` in `vite.config.ts`.
      - Dynamic `require()` calls in source — replace with `await import()`.
      - Missing `index.html` entry reference — re-check Step 7.
    - Run `npm run build` (CRA) in the same CI step to produce the baseline `build/` artifact. Both must succeed before Step 14.

14. Compare bundle output sizes. Run from `<repo_root>`:
    ```bash
    du -sh build/ build-vite/
    find build/static/js -name '*.js' | xargs wc -c | sort -n
    find build-vite/static/js -name '*.js' | xargs wc -c | sort -n
    ```
    Write the comparison to the migration log. A Vite bundle that is more than 20% larger than the CRA bundle (after gzip) warrants investigation before cut-over — log it as a warning, not a hard failure, unless the team has set a size budget.

15. Deploy the Vite build artifact (`build-vite/`) to staging behind the feature flag (`feature_flag_key = true`). Run Lighthouse against `<staging_url>` with the Vite artifact serving:
    ```bash
    npx lighthouse <staging_url> --output json --output-path output/cra-to-vite-lighthouse-post-<timestamp>.json
    ```
    Compare the `categories.performance.score` value against the baseline in `<lighthouse_baseline>`. Compute the delta as `(post_score - baseline_score) * 100` (Lighthouse scores are 0–1).
    - If delta < −`lighthouse_budget_points`: log a warning and continue — this is a soft gate.
    - If delta < −10: this is the hard rollback trigger. Set `feature_flag_key = false`, write the regression to the migration log, STOP: "Lighthouse performance regressed <N> points (threshold: 10). Vite artifact rolled back. Investigate bundle size and code splitting before retrying."

16. Write all outputs declared in Section 7. Run every Equivalence Test in Section 6 and record results in `output/cra-to-vite-equiv-<timestamp>.md`. Evaluate every item in Section 9 Done Criteria; report pass/fail inline, then print the final verdict.

---

# 5. Agent Handoffs

## code-archaeologist

- **File:** `agents/code-archaeologist.md`
- **Triggered by:** Step 4
- **Prompt template:**
  ```
  TASK: Inventory all CSS Modules usage in the repository. For each .module.css or
        .module.scss file found in SCOPE, report:
          - File path and number of class names defined
          - Whether any class names are referenced as string literals in test files
            (e.g. expect(el.className).toBe('...')) — list file path and line number
          - Whether :local() or :global() pseudo-selectors are used anywhere
          - Whether the file is imported in a .tsx/.jsx file that also has Snapshot tests
            (class name changes will break snapshots)
        Separately, list every file that imports 'identity-obj-proxy' — these are Jest
        CSS mock configurations that need to be removed under Vitest.
  REPO_ROOT:   <repo_root>
  SCOPE:       <repo_root>/src
  OUTPUT_FILE: output/cra-to-vite-css-inventory-<timestamp>.md
  FORMAT:      markdown
  ```

---

# 6. Equivalence Tests

<!--
  Tests are run in Step 16. Results written to output/cra-to-vite-equiv-<timestamp>.md.
  "CRA baseline" refers to the build artifact in build/ and the Jest result from the
  pre-migration test run.
-->

| Test Name | Input | Expected Output | Tool |
|-----------|-------|-----------------|------|
| `vitest-suite` | `npx vitest run` from `<repo_root>` (Step 12 result) | Exit code 0. Passed/failed/skipped counts are identical to the pre-migration Jest run (`npm test -- --watchAll=false`). A count divergence is a fail even if exit code is 0. | Bash — captured in Step 12 as `vitest_result`. |
| `vite-build-exits-0` | `npm run build:vite` from `<repo_root>` (Step 13 result) | Exit code 0; `build-vite/index.html` exists and references at least one JS chunk in `build-vite/static/js/`. | Bash — captured in Step 13. |
| `entry-points-present` | `grep -r '<script' build-vite/index.html` and `grep -r '<link rel="stylesheet"' build-vite/index.html` | At least one `<script type="module">` and one `<link rel="stylesheet">` present — Vite bundle is wired into the HTML entry point. | Bash |
| `env-vars-renamed` | `grep -rn 'REACT_APP_' <repo_root>/src/` | No matches. All `REACT_APP_` references replaced with `VITE_` equivalents. | Bash |
| `no-process-env-react-app` | `grep -rn 'process\.env\.REACT_APP_' <repo_root>/src/` | No matches. All usages migrated to `import.meta.env.VITE_*`. | Bash |
| `lighthouse-perf` | Lighthouse JSON at `output/cra-to-vite-lighthouse-post-<timestamp>.json`, `categories.performance.score` field | Score does not regress more than `<lighthouse_budget_points>` points vs. `<lighthouse_baseline>`. Compute as `(post − baseline) × 100`; must be ≥ −`<lighthouse_budget_points>`. | Bash: `node -e "const b=require('<lighthouse_baseline>').categories.performance.score; const p=require('output/...').categories.performance.score; const d=(p-b)*100; process.exit(d < -<lighthouse_budget_points> ? 1 : 0)"` |
| `no-cra-artifacts-in-vite-output` | `grep -r 'react-scripts' build-vite/` | No matches — the Vite build output must not reference CRA tooling. | Bash |

---

# 7. Outputs

| Artifact | Path Pattern | Format | Description |
|----------|-------------|--------|-------------|
| Env var rename map | `output/cra-to-vite-env-map-<timestamp>.json` | json | Array of `{old, new}` pairs mapping `REACT_APP_*` → `VITE_*`; consumed in Step 9 and used as evidence in `env-vars-renamed` equivalence test. |
| Alias map | `output/cra-to-vite-alias-map-<timestamp>.json` | json | Vite `resolve.alias` objects derived from tsconfig `paths`; consumed in Step 6 when writing `vite.config.ts`. |
| CSS inventory | `output/cra-to-vite-css-inventory-<timestamp>.md` | markdown | Produced by `code-archaeologist`; lists CSS Modules files, class name string assertions in tests, and `identity-obj-proxy` usage. Consumed in Step 10. |
| Migration log | `output/cra-to-vite-log-<timestamp>.md` | markdown | Chronological record of every change made (env renames, alias additions, Jest→Vitest config diffs, bundle size comparison), manual-review flags, and the final assumptions list and confidence level. Consumed by `/validate` and the PR reviewer. |
| Lighthouse post report | `output/cra-to-vite-lighthouse-post-<timestamp>.json` | json | Raw Lighthouse JSON captured in Step 15 after deploying the Vite artifact to staging. Performance score diff is computed against `<lighthouse_baseline>`. |
| Equivalence test results | `output/cra-to-vite-equiv-<timestamp>.md` | markdown | Pass/fail verdict for every row in Section 6 with raw command output attached. Required by Section 9 Done Criteria. |

---

# 8. References

- `references/strangler-fig-pattern.md` — the parallel-run + feature flag mechanism used in Steps 8 and 15 is the traffic-shifting pattern described here.
- `references/migration-anti-patterns.md` — "Migrating Without a Seam" (§2): the feature flag is the seam; confirm it exists before starting. "Skipping Equivalence Validation" (§3): the Lighthouse gate is non-negotiable.
- `references/stack-compatibility-matrix.md` — "Framework Compatibility: CRA → Vite" row (add once assessed).
- `skills/03-prepare/` — CI parallelism, feature flag provisioning, and staging environment setup must be complete before this skill runs.
- `skills/04-migrate/frontend/js-to-typescript/` — complete the TypeScript migration before this skill if the repo has mixed `.js`/`.ts` files; Vite's default config handles `.tsx` cleanly but mixed-module CRA projects have edge cases.
- `skills/05-validate/` — run `/validate` after cut-over to confirm the Vite artifact behaves identically to CRA in production traffic.
- `https://vitejs.dev/guide/migration.html` — official Vite migration notes; consult for CommonJS interop and `optimizeDeps` configuration when Step 13 fails.
- `https://vitest.dev/guide/migration.html` — official Jest→Vitest migration guide; the `vi.*` API mapping table is the authoritative reference for Step 11.

---

# 9. Done Criteria

<!--
  Claude evaluates each item and reports pass/fail before declaring this skill complete.
  Any unchecked item means the skill is NOT complete.
  Skill-specific gates (1–10) are above the mandatory universal gates (11–15).
-->

- [ ] `react-scripts` is absent from `package.json` `dependencies` and `devDependencies` — `grep '"react-scripts"' <repo_root>/package.json` returns no matches. (This gate applies only after the feature flag has been flipped to Vite permanently and the CRA scripts have been removed in the cleanup commit.)
- [ ] `scripts.build` in `package.json` is `"vite build"` — `grep '"build":' <repo_root>/package.json` shows `vite build`, not `react-scripts build`.
- [ ] CI runs only `vite build`; the `build:vite` script alias and the `react-scripts build` step have been removed from the CI pipeline config — read the CI config file (`Makefile` / `.github/workflows/*.yml` / `Jenkinsfile`) and confirm.
- [ ] All `REACT_APP_` prefixes are gone from `src/` — `grep -rn 'REACT_APP_' <repo_root>/src/` returns no matches. Also confirmed by `env-vars-renamed` and `no-process-env-react-app` equivalence tests.
- [ ] No CRA config files remain: `config/` directory does not exist; `react-app-env.d.ts` (if present) has been replaced with `/// <reference types="vite/client" />` in `src/vite-env.d.ts` — verify with a glob check.
- [ ] `vitest-suite` equivalence test recorded as **pass** — Vitest test counts match the pre-migration Jest baseline exactly.
- [ ] `vite-build-exits-0` equivalence test recorded as **pass** — build artifact exists at `build-vite/index.html`.
- [ ] `entry-points-present` equivalence test recorded as **pass** — HTML entry has `<script type="module">` and a stylesheet link.
- [ ] `lighthouse-perf` equivalence test recorded as **pass** — Lighthouse Performance score does not regress more than `<lighthouse_budget_points>` points.
- [ ] `no-cra-artifacts-in-vite-output` equivalence test recorded as **pass** — `grep -r 'react-scripts' build-vite/` returns no matches.
- [ ] All output files listed in Section 7 exist at their declared paths — verify each with a file read.
- [ ] Every equivalence test in Section 6 has a recorded result in `output/cra-to-vite-equiv-<timestamp>.md` — no test name is missing from the results file.
- [ ] No equivalence test in Section 6 is recorded as **fail** — grep the results file for `fail`; zero matches required.
- [ ] The migration log includes a confidence level (High / Medium / Low) — grep `output/cra-to-vite-log-<timestamp>.md` for `Confidence:`.
- [ ] The migration log includes a numbered assumptions list — grep `output/cra-to-vite-log-<timestamp>.md` for `Assumptions:`.
