Framework migration in Cursor
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
Section titled “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 first. A migration that spans teams and services is a program; see legacy modernization with agents. Schema changes live in database migration patterns. The same work in other tools is covered by migrations with Claude Code and codebase modernization with Codex.
In what order should a multi-part migration run?
Section titled “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) | 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?
Section titled “How do you run a framework migration in Cursor, step by step?”-
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.
-
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.
-
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.
-
Pin behaviour before each batch. Rewrite implementation-coupled tests against the old code, prove they pass there, and commit them.
-
Run codemods on the mechanical cases. A codemod is deterministic and reviewable as one rule, which makes it cheaper than 95 agent edits.
-
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.
-
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
Section titled “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.
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
Section titled “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-09-26), with alwaysApply: true so it applies in every Agent session. Then prove it loaded with the canary check from Cursor 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.
---description: Framework migration in progress; target patterns for all new and edited codealwaysApply: 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 storesin 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 v8hop, 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
Section titled “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.
// scripts/migration-ratchet.mjs. Run in CI and locally: node scripts/migration-ratchet.mjsimport { 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 shows the workflow.
Port the Webpack config to Vite
Section titled “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
Section titled “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.
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.
Run codemods before the agent
Section titled “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.
# 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 # previewnpx react-codemod pure-component src/components --parser tsx # applynpm 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
Section titled “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.
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 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
Section titled “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.
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
Section titled “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.
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 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?
Section titled “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) | 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 covers setting that up. Why the human reads bridges and auth redirects rather than component bodies is argued in reading evidence instead of code. 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.
What breaks in a Cursor framework migration, and how do you recover?
Section titled “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 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; 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.