# Framework migration in Cursor

URL: https://developertoolkit.ai/en/cursor-ide/build/migration/
Description: Migrate frameworks and major versions in Cursor: inventory in Plan Mode, codemods first, agent batches with locked tests, and a CI ratchet on old patterns.

A framework migration in Cursor is a staged program that moves a codebase between frameworks, libraries or major versions while features keep shipping. Plan Mode inventories the work, codemods handle the mechanical cases, and Cursor's agent migrates the rest in small batches. Locked behaviour tests and a CI ratchet on legacy patterns prove each batch.

Your React app runs 180 class components, 45 Redux containers wired with `connect()`, 30 React Router 5 routes and a hand-written Webpack config and has to reach hooks, Zustand, the current React Router and Vite, but nobody will approve a three-month feature freeze. The individual transformations are easy for an agent; keeping 180 of them correct, and stopping the codebase from settling at half old and half new, is the hard part. This page is for developers running that migration in Cursor and for tech leads who have to prove it is finished.

## What a staged framework migration in Cursor gives you

- An order of operations for a multi-axis migration, and the dependency that decides it.
- A Plan Mode prompt that produces an inventory from real counts, not model estimates.
- A migration project rule and a CI ratchet script that stop new code from using the old patterns.
- Five copy-paste prompts: inventory, pinning behaviour, a component batch, a store migration and a router hop.
- A verification table that says what proves each batch and who signs off, plus recovery steps for six common failures.

**Scope.** This page covers framework, library and major-version moves inside one application. An untested codebase needs [characterization tests and a legacy refactor](/en/cursor-ide/build/legacy-refactor/) first. A migration that spans teams and services is a program; see [legacy modernization with agents](/en/workflows/build/legacy-modernization/). Schema changes live in [database migration patterns](/en/cookbook/database-recipes/migration-patterns/). The same work in other tools is covered by [migrations with Claude Code](/en/claude-code/build/migrations/) and [codebase modernization with Codex](/en/codex/build/codebase-modernization/).

## In what order should a multi-part migration run?

Run one axis per branch and one major version per hop, and let dependencies decide the order. For the example stack, the order below works because each step makes the next one smaller. The version constraints come from the packages' own `peerDependencies` on npm, checked 2026-09-26.

| Order | Axis | Why at this point | Gate before the next step |
| --- | --- | --- | --- |
| 1 | Pin behaviour | Every later step needs tests that describe behaviour, not implementation | Tests pass on the **old** code and are committed |
| 2 | Webpack to Vite | Configuration only; touches no component, so it can merge before any code changes ([port the Webpack config to Vite](#port-the-webpack-config-to-vite)) | Build, dev server and E2E smoke tests pass on Vite |
| 3 | Class to function components | Router hooks (`useHistory` now, `useNavigate` after the v6 hop) and store hooks need function components; also removes string refs and legacy context, which React 19 dropped | Ratchet count of class components reaches 0, except the error boundaries listed in `plan.md` |
| 4 | Redux to Zustand, one store at a time | Connected containers are now function components, so a store hook drops in | Each store's Redux module is deleted |
| 5 | React 18 to React 19 | React Router 8 declares `react >=19.2.7` as a peer | Full suite and smoke tests pass on React 19 |
| 6 | React Router 5 to 6, then 7, then 8 | `react-router-dom-v5-compat` (6.30.6) lets v5 and v6 routes coexist; v7 needs React 18 or later | Each hop merges on its own |

The last row matters most. `react-router` 8.4.0 is current, and `react-router-dom` stops at 7.18.4, so the v8 hop also moves imports to the `react-router` package. React Router 8 shipped on 2026-06-17, which may be after the training cutoff of the model you use, so the agent will write v6 or v7 code from memory unless you point it at the upgrade guide.

## How do you run a framework migration in Cursor, step by step?

1. **Inventory the migration in Plan Mode.** Plan Mode "creates detailed implementation plans before writing any code" (Cursor docs, checked 2026-08-28), so nothing changes while you review the scope. Ask for counts from commands, because a model's estimate of "about 95 simple components" is a guess.

2. **Write the migration rule and the plan file.** The rule says what new code must look like. The plan file holds the batch list and progress. Keep progress out of the rule, because a rule is loaded into every session and a hand-edited count goes stale.

3. **Add the ratchet to CI.** A script counts files that still use each legacy pattern and fails when any count rises. It is the only check that stops the migration from sliding backwards.

4. **Pin behaviour before each batch.** Rewrite implementation-coupled tests against the old code, prove they pass there, and commit them.

5. **Run codemods on the mechanical cases.** A codemod is deterministic and reviewable as one rule, which makes it cheaper than 95 agent edits.

6. **Migrate the rest with the agent in batches of five to ten files.** Start a fresh chat per batch, keep the tests read-only, and merge each batch on its own branch.

7. **Migrate state one store at a time, then hop the router one major at a time.** Delete each bridge and compatibility layer as soon as its last consumer moves.

### Inventory the codebase in Plan Mode

Switch the agent to Plan Mode and paste this prompt. It asks for the command behind every number, so you can rerun them later.

**Copy-paste prompt: migration inventory in Plan Mode**

```text
Plan a migration of this React app. Do not change any source file.

FROM: class components, Redux with connect(), React Router 5 (react-router-dom 5),
Webpack config in webpack.config.js, React 18.
TO: function components with hooks, Zustand, React Router 8 (package react-router),
Vite, React 19.

1. Count, with the exact grep or rg command you ran for each number:
   class components, components wrapped in connect(), uses of withRouter,
   <Switch> and <Redirect>, Redux reducers under src/store, and test files that
   use enzyme, .instance() or .state().
2. Split class components into: render-only (no state, no lifecycle, no refs),
   stateful with lifecycle methods, and complex (HOCs, render props, refs,
   getDerivedStateFromProps, shouldComponentUpdate).
3. List the Redux stores and, for each, the components that read from it.
4. Propose batches of 5-10 files that do not share files with other batches,
   ordered: pin tests, Webpack to Vite, components, stores, React 19, router hops.
5. After I approve the plan, save it to docs/migration/plan.md as a checklist,
   with the count commands at the top so they can be rerun.

Ask me about any pattern you cannot classify instead of guessing.
```

Read the plan, not the code. You are checking that the counts come with commands, that no batch shares files with another, and that the complex components are listed by name. Those names are where your review time will go later.

### Write the migration rule

Save the rule as `.cursor/rules/migration.mdc`, the `.mdc` format that Cursor's project rules use (Cursor's rules documentation, checked 2026-10-12, which also says a plain `.md` file in `.cursor/rules` is ignored), with `alwaysApply: true` so it applies in every Agent session. Then prove it loaded with the canary check from [Cursor project rules](/en/cursor-ide/quick-start/project-rules/): run the canary prompt, look for `Canary: migration-3b1e` in the answer, then delete the canary line: a rule you have not seen in an answer is a rule you cannot rely on.

The router clause points at the plan instead of naming a target, because the app stays on React Router 5 until the last step. On v5, v6 and v7, `BrowserRouter`, `Link` and `NavLink` live only in `react-router-dom`, so an always-on "no `react-router-dom` imports" rule would make every component batch break it. That rule belongs in the v8 hop prompt below.

```markdown
---
description: Framework migration in progress; target patterns for all new and edited code
alwaysApply: true
---
A migration is in progress. Plan and progress: docs/migration/plan.md.

New or edited code MUST use: function components with hooks and Zustand stores
in src/stores/.
New code MUST NOT use: class components, connect() or mapStateToProps.
Exception: keep error boundaries (componentDidCatch / getDerivedStateFromError)
as classes, or use react-error-boundary. React has no hook for them.

Router imports: follow the current hop in docs/migration/plan.md. Until the v8
hop, keep existing react-router-dom imports, and do not add withRouter, <Switch>
or <Redirect> to new code.

When you migrate a file:
- Do not edit test files unless the task says so. A failing test is a finding; report it.
- Do not change behaviour, props, exports or CSS class names.
- Run `npm run typecheck`, `npx vitest run <changed dirs>` and
  `node scripts/migration-ratchet.mjs` before you say you are done.

Canary: migration-3b1e (remove after the load check)
```

### Add the legacy-pattern ratchet to CI

The ratchet counts files per legacy pattern and compares them with a committed baseline. A count may fall, never rise. Lower the baseline in the same pull request that removes the old pattern, so progress becomes a reviewable number. The baseline and the script sit in the same pull request as the code, though, so an agent that cannot bring a count down can raise the baseline or edit the script and stay green. Put `docs/migration/baseline.json` and `scripts/migration-ratchet.mjs` under a `CODEOWNERS` entry with a required code-owner review, and treat any diff that raises a baseline number as a finding.

```js
// scripts/migration-ratchet.mjs. Run in CI and locally: node scripts/migration-ratchet.mjs
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

const patterns = {
  classComponents: String.raw`extends (React\.)?(Pure)?Component\b`,
  reactRedux: String.raw`from ['"]react-redux['"]`,
  withRouter: String.raw`\bwithRouter\b`,
  routerDom: String.raw`from ['"]react-router-dom['"]`,
};
const include = ['ts', 'tsx', 'js', 'jsx'].map((ext) => `--include=*.${ext}`);
const baseline = JSON.parse(readFileSync('docs/migration/baseline.json', 'utf8'));

let failed = false;
for (const [name, re] of Object.entries(patterns)) {
  let out = '';
  try {
    out = execFileSync('grep', ['-rlE', re, 'src', ...include]).toString().trim();
  } catch (err) {
    if (err.status !== 1) throw err; // grep exits 1 when nothing matches
  }
  const count = out ? out.split('\n').length : 0;
  if (!(name in baseline)) {
    console.log(`${name}: not ratcheted yet (no baseline entry)`);
    continue;
  }
  const allowed = baseline[name];
  console.log(`${name}: ${count} files (baseline ${allowed})`);
  if (count > allowed) failed = true;
}
process.exit(failed ? 1 : 0);
```

`docs/migration/baseline.json` starts with the file counts the ratchet prints on its first run: run it once and paste the numbers. The ratchet counts files, not components, so a file with two classes counts once and the numbers can be lower than the inventory's. An illustrative baseline looks like `{"classComponents": 172, "reactRedux": 46, "withRouter": 22}`. `reactRedux` counts files that import from `react-redux`, not calls named `connect(`, so a new `socket.connect()` or `db.connect()` does not trip it; its count includes the root `<Provider>` file, which is why it starts one above the 45 containers. `classComponents` bottoms out at the number of error boundaries, for example 3, not at 0, because React still has no hook equivalent for `componentDidCatch` and `getDerivedStateFromError`; list those files in `plan.md`. A pattern with no baseline entry is reported but not enforced. Leave `routerDom` out until the v8 hop, because new components legitimately import `Link` from `react-router-dom` before then; the v8 hop pull request adds `"routerDom": 0`. Add the script as a required CI step next to your type check and tests; [CI/CD with Cursor](/en/cursor-ide/ship/ci-cd/) shows the workflow.

### Port the Webpack config to Vite

The Webpack-to-Vite step is configuration work and touches no component, so it runs second, right after the tests are pinned. Ask the agent to port `resolve.alias`, `devServer.proxy` and environment variables (by default, Vite exposes only variables with the `VITE_` prefix through `import.meta.env`). Keep the Webpack config until the Vite build passes the smoke tests, and delete it in a separate commit.

### Pin behaviour before the agent touches a batch

Tests written with Enzyme or against `.instance()` and `.state()` test the class, not the behaviour, and they break on the first function component. If the agent rewrites those tests in the same change as the component, the tests prove nothing. So rewrite them first, against the old code.

**Copy-paste prompt: pin behaviour on the old implementation**

```text
Batch 7 from docs/migration/plan.md: src/components/UserProfile.tsx,
src/components/OrderRow.tsx, src/components/AddressForm.tsx.

For each component, rewrite its test file with React Testing Library so it tests
only what a user sees and does: rendered text, roles, form input, callbacks fired
with which arguments, and requests made (mock fetch at the network boundary).
No .instance(), .state(), shallow rendering or snapshot of the whole tree.

Do NOT change the components. Run `npx vitest run src/components` and show that
every rewritten test passes against the current class components. Then list any
behaviour of each component that no test covers yet, and add tests for it.
```

Review the tests, commit them as their own commit, tag it and push the tag, for example `git tag batch-7-pinned && git push origin batch-7-pinned`. Without the push, CI has no such ref and the test-diff check below errors out instead of proving anything; the CI checkout also needs the tag, for example `fetch-depth: 0` in `actions/checkout`. The tests are now the contract for the batch. For code with no tests at all, use the approval and golden-master techniques in [characterization tests](/en/workflows/test/characterization-tests/).

### Run codemods before the agent

For render-only class components, the React team's `react-codemod` has a `pure-component` transform. It converts classes that only have a render method, statics and props, and no refs, and skips everything else.

```bash
# Terminal, repo root. react-codemod 5.4.4 on npm, checked 2026-09-26.
npx react-codemod pure-component src/components --parser tsx --dry --print   # preview
npx react-codemod pure-component src/components --parser tsx                 # apply
npm run typecheck && npx vitest run && node scripts/migration-ratchet.mjs
```

`--parser tsx` skips the dialect question, but the command still asks two style questions (arrow functions, props destructuring), so it does not run unattended. The apply run refuses to start on uncommitted changes, so commit the pinned tests first. Check that the dry-run output keeps the `Props` type on each converted component.

You review one transform rule instead of 95 agent diffs, and the output is identical every run. Commit the codemod result separately from any agent work, so a reviewer can see which changes were mechanical.

### Migrate a component batch with the agent

Open a new chat for each batch, so the agent is not anchored on the previous batch's decisions. Name the files with `@` so the context holds only what the batch needs.

**Copy-paste prompt: migrate a batch of stateful class components**

```text
Migrate batch 7 from docs/migration/plan.md to function components:
@src/components/UserProfile.tsx @src/components/OrderRow.tsx @src/components/AddressForm.tsx

Mapping rules:
- this.state / setState -> one useState per independent piece of state
- componentDidMount / componentDidUpdate(prevProps) -> useEffect with the exact
  dependencies; componentWillUnmount -> the effect's cleanup
- getDerivedStateFromProps -> compute during render; useMemo only if it is costly
- Class methods passed as props -> useCallback
- Error boundaries (componentDidCatch / getDerivedStateFromError) stay classes;
  React has no hook for them. List them in your report; do not convert them.

Constraints:
1. Test files are read-only. If a test fails, stop and tell me which and why.
2. Keep props, exports, rendered markup and CSS class names identical.
3. Do not touch files outside this batch.
4. When done, run: npm run typecheck, npx vitest run src/components,
   node scripts/migration-ratchet.mjs. Report the three results and every place
   where the mapping was not one-to-one (timers, subscriptions, refs).
```

The last line of the prompt is your review list. You read the non-one-to-one mappings, not the whole diff. To run several batches at once, give each one its own worktree; Cursor's worktrees "let Agent work in isolated Git checkouts" (Cursor docs, checked 2026-08-28), and [the Agents Window](/en/cursor-ide/build/agents-window/) shows how to supervise them. Keep batches disjoint, or the merges will conflict in shared store files.

### Migrate Redux to Zustand one store at a time

State is the riskiest axis, because one store feeds many components. Move one store per branch, with a one-way bridge while old and new consumers coexist.

**Copy-paste prompt: move one Redux store to Zustand**

```text
Move the cart store from Redux to Zustand.
Read: @src/store/cartReducer.ts @src/store/cartActions.ts @src/store/cartSelectors.ts

1. Create src/stores/cart.ts with Zustand: the same state shape as the reducer's
   initial state, one store method per action, and selectors as hook selectors.
2. Create src/stores/bridges/cart-bridge.ts that copies Zustand cart state into
   Redux in ONE direction only, so connected components keep working. Add a
   comment at the top: "Delete when no file imports cartSelectors."
3. Write tests for src/stores/cart.ts that replay the existing reducer tests'
   action sequences and assert the same resulting state.
4. Migrate the single simplest consumer from connect() to useCartStore().
5. Run npm run typecheck, npx vitest run, node scripts/migration-ratchet.mjs,
   and list every file that still imports from src/store/cart*.
```

Step 3 reuses the reducer's tests as an oracle for the new store: the same action sequence must produce the same state. When the list in step 5 is empty, delete the bridge and the Redux module in one commit and lower the `reactRedux` baseline.

### Hop the router one major version at a time

Do each hop on its own branch: v5 to v6 through the compat package, then v6 to v7, then v7 to v8. Do not let the agent work from memory for a major version that may be newer than its training data.

**Copy-paste prompt: one router hop from the upgrade guide**

```text
Upgrade React Router from v7 to v8 in this repo. Before editing anything:
1. Read the v7-to-v8 upgrade guide and CHANGELOG for the installed version
   (node_modules/react-router/docs/upgrading/v7.md, "Upgrading from v7", and
   node_modules/react-router/CHANGELOG.md). Summarize every
   breaking change that applies to this codebase, with the files affected.
   Stop and wait for my approval.

After approval:
2. Move every import from react-router-dom to react-router (RouterProvider
   to react-router/dom); after this hop no file may import from
   react-router-dom. Apply only the changes from your
   summary. No refactoring beyond them.
3. Run npm run typecheck, npx vitest run, npx playwright test e2e/smoke,
   and node scripts/migration-ratchet.mjs. Report each result.
```

The react-router 8.4.0 package ships its upgrade guides in `docs/upgrading/`, and the v7 to v8 one is `docs/upgrading/v7.md` ("Upgrading from v7"; checked with `npm pack react-router@8.4.0` on 2026-10-02), so the agent reads the guide for the installed version with no extra context step. That guide also says `RouterProvider` moves to `react-router/dom`, not to `react-router`. To give the agent current docs for other libraries, you can also add the Context7 MCP server (`@upstash/context7-mcp`, 4.1.1 on npm, checked 2026-09-26); [MCP setup in Cursor](/en/cursor-ide/quick-start/mcp-setup/) shows how. For the v5 to v6 hop, install `react-router-dom-v5-compat` and move routes one at a time inside the running v5 app, then remove the package when no v5 route remains.

## How do you prove a migration batch without reading every line?

Each batch is proven by checks the agent cannot edit, and a human reads only the parts where a mapping was not mechanical.

| Check | What it proves | Who owns it |
| --- | --- | --- |
| Behaviour tests pass on the old code before the batch | The tests describe behaviour, not the class | Developer running the batch |
| `git diff --exit-code batch-7-pinned -- '*.test.ts' '*.test.tsx'` after the batch | The agent did not edit the contract to get to green | Developer locally, CI on the pull request |
| `npm run typecheck`, lint, `npx vitest run` | The batch is consistent with the rest of the codebase | CI, as required checks |
| `node scripts/migration-ratchet.mjs` | No legacy pattern count went up | CI, as a required check |
| Playwright smoke tests with `toHaveScreenshot()` on the top routes | Pages render and navigate the same as before | CI; the tech lead picks the routes |
| Bugbot on the pull request | A second reader for bugs the tests missed ([Bugbot](/en/cursor-ide/ship/bugbot/)) | Reviewer triages findings |
| Human reading | Bridges, store shape, route guards and auth redirects, and the agent's list of non-one-to-one mappings | The tech lead signs off each store and router hop |

For the smoke tests, record the screenshots on the old build before the batch and compare against them after; [E2E testing](/en/workflows/test/e2e-testing/) covers setting that up. Why the human reads bridges and auth redirects rather than component bodies is argued in [reading evidence instead of code](/en/ladder/evidence-not-diffs/). Roll back a whole batch by reverting its merge commit; roll back one agent turn inside a batch with a checkpoint, as described in [checkpoints and branching](/en/cursor-ide/build/checkpoints-branching/).

## What breaks in a Cursor framework migration, and how do you recover?

**Tests stay green because the agent rewrote them.** The component changed and so did its test, in the same diff. Recovery: restore the tests from the pinned tag with `git checkout batch-7-pinned -- '*.test.ts' '*.test.tsx'`, rerun the batch prompt, and make the test-diff check a required CI step. [Protecting the oracle](/en/workflows/test/protect-the-oracle/) shows a hook that blocks test edits during implementation.

**New code keeps using the old pattern.** The agent copies what it sees most, and at 25% migrated that is the old pattern. Recovery: check that the rule sits in `.cursor/rules/` as `.mdc` with `alwaysApply: true`, then re-add the canary line and rerun the canary prompt from [Cursor project rules](/en/cursor-ide/quick-start/project-rules/); if the canary is missing from the answer, the rule is not loaded. The ratchet catches whatever the rule misses, which is why both exist.

**The agent writes APIs from the wrong major version.** It produces v6 routing code in a v8 codebase, or a Zustand pattern from an older major. Recovery: make "read the installed CHANGELOG first and summarize the breaking changes" the first step of every hop, as in the router prompt, and add a Context7 lookup.

**The Redux-to-Zustand bridge causes extra renders or loops.** A two-way sync writes back and forth on every change. Recovery: keep the bridge one-way, count bridges in the plan file, and delete each one as soon as its last Redux consumer migrates.

**The migration stalls at the complex tail.** The last components hold HOCs, refs and render props, and nobody picks them up. Recovery: the ratchet makes the tail a visible number in every pull request. Give each complex component its own Plan Mode session and its own branch, and schedule them rather than leaving them for spare time.

**A batch is too large to verify.** A 40-file batch produces a diff and a failure list nobody can hold in their head, and the agent loses track of its own changes. Recovery: revert the batch, split it to five to ten files by the plan's dependency list, and start each part in a fresh chat.

## Where to go next after a framework migration

- [Refactoring legacy code with Cursor](/en/cursor-ide/build/legacy-refactor/) — Pin behaviour and change one seam per commit when the code has no tests.
- [Legacy modernization with agents](/en/workflows/build/legacy-modernization/) — Run a cross-team migration as a gated program with cut-over evidence.
- [Test-driven development in Cursor](/en/cursor-ide/test/testing-tdd/) — Lock tests as the contract and prove them with mutation testing.
- [Large codebase strategies](/en/cursor-ide/build/large-codebase-strategies/) — Keep context focused when a migration spans hundreds of files.
