DEV Community

Cover image for I Built a Zero-False-Positive Codemod That Migrates React Router v6 v7 in 3 Seconds
Ankit raj
Ankit raj

Posted on • Originally published at github.com

I Built a Zero-False-Positive Codemod That Migrates React Router v6 v7 in 3 Seconds

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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() });
}
Enter fullscreen mode Exit fullscreen mode

✅ 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() };
}
Enter fullscreen mode Exit fullscreen mode

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-dom entries in package.json
  • Correctly rewrote isolated react-router-dom imports without touching adjacent react-admin or react-dom imports
  • All TypeScript types preserved perfectly

What the react-petstore test revealed:

  • 15 files modified cleanly — imports rewritten, future flags injected
  • Test files with MemoryRouter correctly 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
Enter fullscreen mode Exit fullscreen mode

Instead of giving up, I built a dual execution path:

  1. Custom Node.js orchestrator (apply-codemod.js) — A robust, zero-dependency runner that compiles TypeScript transforms via ts-node and applies them directly.

  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Built by Ankit RajLinkedIn𝕏

Top comments (0)