The Pain Every React Developer Knows
You've been there. React Router drops a new major version, and suddenly your entire codebase is littered with deprecation warnings. Every import needs updating. New flags need injecting. Deprecated APIs need unwrapping.
React Router v7 introduced four simultaneous breaking changes that touch virtually every file in a React application:
❌ react-router-dom → must become react-router
❌ 6 future flags must be added to every Router component
❌ json() is deprecated → return plain objects
❌ defer() is deprecated → return plain objects
For a 50-file codebase, you're looking at 2+ hours of tedious find-and-replace work. Miss one import? Your app breaks. Forget a flag? Runtime warnings everywhere. Accidentally touch a string literal? Silent corruption.
I built a tool that does this in 3 seconds with zero mistakes.
Why Regex is the Wrong Tool
Your first instinct might be: "Just use find-and-replace."
Here's why that fails spectacularly:
Problem 1: Strings and Comments
// A regex matching "react-router-dom" would corrupt these:
const docs = "See react-router-dom docs for more info"; // ← string literal
// TODO: Migrate react-router-dom to react-router // ← comment
const url = "https://npm.im/react-router-dom"; // ← URL
A regex can't tell the difference between an import statement and the same text inside a string. AST parsing can.
Problem 2: Complex JSX Injection
How do you regex-inject 6 props into a component that might already have some props, might span multiple lines, and might have nested children?
<BrowserRouter
basename="/app"
// some dev comment here
>
<App />
</BrowserRouter>
You'd need to handle every formatting variant. AST parsing treats this as a single node with child nodes — trivial to manipulate safely.
Problem 3: Idempotency
Run a regex twice and you might get react-routerr from a double replacement. Or duplicate future flags. AST matching is inherently idempotent — it matches structural patterns, not character sequences.
The Solution: AST-Powered Transforms
I chose ast-grep — a Rust-based AST tool with Node.js bindings. It's ~100× faster than Babel and supports structural pattern matching out of the box.
Instead of matching text, we match tree structures:
Source: import { Link, Route } from 'react-router-dom';
AST: import_statement
├── named_imports
│ ├── import_specifier ("Link")
│ └── import_specifier ("Route")
└── string ("react-router-dom") ← We match THIS node
The pattern import { $$$IMPORTS } from 'react-router-dom' matches the shape of the AST, not the text. This means:
- ✅ Matches regardless of whitespace or formatting
- ✅ Never matches inside strings or comments
- ✅ Preserves all import specifiers exactly as written
- ✅ Preserves inline comments and type annotations
The 4-Step Migration Pipeline
Step 1: Package Migration
Reads package.json, replaces react-router-dom with react-router@7, handles duplicate entries gracefully.
Step 2: Import Rewriting
Parses every .ts, .tsx, .js, .jsx file via AST. Finds all import ... from 'react-router-dom' statements and rewrites them to 'react-router'. Formatting, aliases, and comments are all preserved.
Step 3: Future Flag Injection (The Hard Part)
This was the most challenging transform. The problem: a developer might have already added some flags manually. Blindly injecting all 6 would create duplicates.
The solution — a smart-merge algorithm:
const existingFlags = ["v7_startTransition", "v7_relativeSplatPath"];
const allRequired = [
"v7_relativeSplatPath", "v7_startTransition",
"v7_fetcherPersist", "v7_normalizeFormMethod",
"v7_partialHydration", "v7_skipActionErrorRevalidation"
];
// Only inject what's missing
const missing = allRequired.filter(f => !existingFlags.includes(f));
// → ["v7_fetcherPersist", "v7_normalizeFormMethod",
// "v7_partialHydration", "v7_skipActionErrorRevalidation"]
This guarantees idempotent execution — run it 10 times, get the exact same output.
Step 4: API Modernization
Finds deprecated json() and defer() calls, unwraps them to plain return objects, and cleans up the now-unused imports.
Before & After
Here's what the codemod does to your code:
❌ Before (v6):
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { json, defer } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</BrowserRouter>
);
}
export function loader() {
return json({ user: getUser() });
}
✅ After (v7):
import { BrowserRouter, Routes, Route } from 'react-router';
function App() {
return (
<BrowserRouter future={{
v7_relativeSplatPath: true,
v7_startTransition: true,
v7_fetcherPersist: true,
v7_normalizeFormMethod: true,
v7_partialHydration: true,
v7_skipActionErrorRevalidation: true
}}>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</BrowserRouter>
);
}
export function loader() {
return { user: getUser() };
}
Every import rewritten. Every flag injected. Every deprecated API unwrapped. In under 3 seconds.
Safety First: Backup & Rollback
Before touching a single file, the engine creates a complete snapshot:
- SHA-256 hash per file for integrity verification
-
Full file-level snapshots in
.codemod-backup/ -
One-command rollback:
node apply-codemod.js --rollback
If anything goes wrong — --rollback restores your entire codebase instantly, verified byte-for-byte against the original hashes.
Real-World Validation
I didn't just test this on toy projects. I ran it against real open-source codebases:
| Repository | Stack | Files Scanned | Files Modified | False Positives |
|---|---|---|---|---|
| react-admin | TypeScript + v6 | 45 | 3 | 0 |
| react-petstore | JavaScript + v6 | 34 | 15 | 0 |
Zero false positives across every validation. The AST approach pays for itself immediately.
What the react-admin test revealed:
- Handled legacy duplicate
react-router-domentries inpackage.json - Correctly rewrote isolated
react-router-domimports without touching adjacentreact-adminorreact-domimports - All TypeScript types preserved perfectly
What the react-petstore test revealed:
- 15 files modified cleanly — imports rewritten, future flags injected
- Test files with
MemoryRoutercorrectly updated - All component formatting preserved exactly as written
The Engineering Challenge I Didn't Expect
During development, the official Codemod CLI consistently failed with unresolvable errors:
Error: no variant of enum StepAction found in flattened data
Error: missing field `schema_version`
Error: Package too large: 1087194363 bytes
Instead of giving up, I built a dual execution path:
Custom Node.js orchestrator (
apply-codemod.js) — A robust, zero-dependency runner that compiles TypeScript transforms viats-nodeand applies them directly.Fixed workflow for the Registry — Reverse-engineered the correct schema by scaffolding a reference project, then adapted the transforms to fit.
Lesson learned: A resilient engine that works is worth more than a perfect integration that doesn't.
Key Takeaways
1. AST > Regex, Always
For any code transformation that needs to be reliable at scale, AST-based approaches are the only viable path. The upfront complexity pays for itself immediately in zero false positives.
2. Idempotency is Non-Negotiable
The smart-merge pattern — check what exists, only add what's missing — should be the default for any code transformation tool. Developers will run your tool multiple times. It must be safe every time.
3. Test with Real Code, Not Just Fixtures
Synthetic test fixtures catch structural correctness. Real-world repos catch edge cases you never imagined — duplicate dependency entries, mixed import styles, unusual formatting patterns.
4. Build the Bypass First
When infrastructure fails (and it will), having a direct execution path saves the project.
Try It Yourself
One command. That's it.
npx codemod react-router-v6-to-v7
Or run locally:
git clone https://github.com/Ankit-raj-11/react-router-v6-to-v7.git
cd react-router-v6-to-v7
npm install
node apply-codemod.js ./path-to-your-project
Links
| 💻 Source Code | github.com/Ankit-raj-11/react-router-v6-to-v7 |
| 📦 Codemod Registry | app.codemod.com/registry/react-router-v6-to-v7 |
| 🎥 Demo Video | youtu.be/sYSHvwAp1Ts |
| 📖 Full Case Study | Engineering Deep-Dive |
| 🔧 PR to Codemod Docs | codemod/codemod#2167 |
If this saved you time (or if you've been procrastinating on a React Router upgrade 😄), give it a ⭐ on GitHub!
Have questions or ran into edge cases? Drop a comment below — I'd love to hear about your migration experience.
Top comments (0)