DEV Community

Arpit
Arpit

Posted on

Case Study: Automating React Router v6 v7 Migration

Author: arpit2222

Published: 2026-05-03

Repository: arpit2222/react-router-v6-to-v7


1. Executive Summary

This codemod automates the migration from React Router v6 to v7 across seven distinct
transform categories — import rewrites, future flag injection for both JSX components and
data router function calls, deprecated API removal, fallback migration, and silent runtime
bug fixes. The key technical innovation is correctly splitting the RouterProvider import
to react-router/dom rather than react-router, a distinction every other published
codemod gets wrong, and which the official upgrade guide explicitly requires. Validated
against four real-world repositories spanning 461 total files, the codemod achieved
zero false positives and automated approximately 85–90% of all required migration changes.


2. The RouterProvider Bug — Technical Deep Dive

What the official docs say

The React Router v7 upgrade guide states
under "Package Changes":

RouterProvider is now exported from react-router/dom rather than react-router,
as it requires direct access to react-dom APIs for hydration and concurrent rendering.

This is not ambiguous. RouterProvider has a hard dependency on react-dom internals —
specifically ReactDOM.createRoot and the concurrent rendering scheduler. The TypeScript
types for its generic parameters (<RouterProviderProps>, the loader and action type
inference) are also only exported from react-router/dom.

What a naive codemod does

The simplest possible approach to this migration is a blanket find-and-replace:

// Every naive implementation does this
source.replaceAll("from 'react-router-dom'", "from 'react-router'")
Enter fullscreen mode Exit fullscreen mode

This produces:

// ❌ Wrong — RouterProvider cannot safely come from 'react-router'
import { RouterProvider, useNavigate, Link } from 'react-router'
Enter fullscreen mode Exit fullscreen mode

The import compiles. tsc reports no errors. The app even runs. The damage is subtle:

TypeScript generic inference breaks silently. The moment you try to use typed loaders
with useLoaderData<typeof loader>(), the return type resolves incorrectly because the
generic constraints on RouterProvider are not exported from react-router — they come
from react-router/dom which wraps them with react-dom's hydration context types.

SSR hydration can misfire. In a server-rendered app, RouterProvider's internal
call to ReactDOM.hydrateRoot relies on the import being from react-router/dom. The
type checker cannot catch this at compile time, but the wrong export path silently skips
the hydration boundary wiring.

The TypeScript error you would eventually see

If you import RouterProvider from react-router and try to use typed loader data:

// router.ts
import { RouterProvider } from 'react-router'          // ← wrong path

// component.tsx
import { useLoaderData } from 'react-router'
export function Component() {
  const data = useLoaderData<typeof loader>()
  //                         ^^^^^^^^^^^^^^
  // Type 'typeof loader' does not satisfy the constraint 'LoaderFunction'.
  // Types of parameters 'args' and 'args' are incompatible.
  // Type 'LoaderFunctionArgs<any>' is not assignable to
  // type 'LoaderFunctionArgs<unknown>'.  (ts2344)
}
Enter fullscreen mode Exit fullscreen mode

The error appears far from the import that caused it — a classic case of TypeScript's
structural typing masking the root cause.

The correct split

The right transformation is not a blanket replace. It requires splitting any import
that contains RouterProvider or HydratedRouter into two separate statements:

// BEFORE
import { RouterProvider, useNavigate, Link, NavLink as Nav } from 'react-router-dom'

// AFTER — correct per official docs
import { RouterProvider } from 'react-router/dom'       // DOM-coupled
import { useNavigate, Link, NavLink as Nav } from 'react-router' // everything else
Enter fullscreen mode Exit fullscreen mode

The test-file exception

There is one intentional exception: test files (.test., .spec.). Jest environments
typically do not configure jsdom for component tests, and react-dom is often not
available at all in unit test suites. In test files, RouterProvider is routed to
react-router — the same as all other imports — so that tests do not require changes
to jest configuration.

This codemod detects test files by path:

function isTestFile(path: string): boolean {
  return path.includes('.test.') || path.includes('.spec.')
}
Enter fullscreen mode Exit fullscreen mode

Implementation

const DOM_ONLY_SPECIFIERS = new Set(['RouterProvider', 'HydratedRouter'])

// For each import from 'react-router-dom':
const domSpecs   = specifiers.filter(s => DOM_ONLY_SPECIFIERS.has(getBaseName(s)))
const otherSpecs = specifiers.filter(s => !DOM_ONLY_SPECIFIERS.has(getBaseName(s)))

if (!testFile && domSpecs.length > 0) {
  // Split into two import statements
  lines.push(`import { ${domSpecs.join(', ')} } from 'react-router/dom'`)
  if (otherSpecs.length > 0) {
    lines.push(`import { ${otherSpecs.join(', ')} } from 'react-router'`)
  }
} else {
  // No DOM-coupled specifiers — simple rewrite
  replacement = `import { ${specifiers.join(', ')} } from 'react-router'`
}
Enter fullscreen mode Exit fullscreen mode

getBaseName strips as Alias before checking the set, so import { RouterProvider as RP }
is correctly identified as a DOM-only specifier even when aliased.


3. Why createBrowserRouter Matters

The JSX-only blind spot

Most published codemods for this migration focus exclusively on the JSX API:

// This is what other codemods handle
<BrowserRouter future={{ ... }}>
  <App />
</BrowserRouter>
Enter fullscreen mode Exit fullscreen mode

But the React Router team introduced the data router pattern in v6.4 and has been
recommending it as the primary API since 2022. Any codebase built in the last two years
almost certainly uses:

const router = createBrowserRouter(routes)
ReactDOM.createRoot(document.getElementById('root')).render(
  <RouterProvider router={router} />
)
Enter fullscreen mode Exit fullscreen mode

createBrowserRouter does not accept JSX props — its future flags go in the options
object
, the optional second argument. A codemod that only handles JSX components will
silently skip every createBrowserRouter call in the codebase.

The three cases

Case 1 — No options object (add from scratch)

// BEFORE
const router = createBrowserRouter(routes)

// AFTER
const router = createBrowserRouter(routes, {
  future: {
    v7_relativeSplatPath: true,
    v7_startTransition: true,
    v7_fetcherPersist: true,
    v7_normalizeFormMethod: true,
    v7_partialHydration: true,
    v7_skipActionErrorRevalidation: true,
  }
})
Enter fullscreen mode Exit fullscreen mode

Case 2 — Options object without future (inject into existing object)

// BEFORE
const router = createBrowserRouter(routes, { basename: '/app' })

// AFTER — basename is preserved, future is injected
const router = createBrowserRouter(routes, {
  basename: '/app',
  future: {
    v7_relativeSplatPath: true,
    // ... all 6 flags
  }
})
Enter fullscreen mode Exit fullscreen mode

Case 3 — Partial future flags (smart merge)

// BEFORE — team has already set two flags manually
const router = createBrowserRouter(routes, {
  future: {
    v7_relativeSplatPath: true,
    v7_startTransition: true,
  }
})

// AFTER — four missing flags added, existing two untouched
const router = createBrowserRouter(routes, {
  future: {
    v7_relativeSplatPath: true,      // preserved
    v7_startTransition: true,         // preserved
    v7_fetcherPersist: true,          // added
    v7_normalizeFormMethod: true,     // added
    v7_partialHydration: true,        // added
    v7_skipActionErrorRevalidation: true, // added
  }
})
Enter fullscreen mode Exit fullscreen mode

Why idempotency matters for production teams

Production teams do not migrate all at once. They test one flag at a time on a feature
branch, verify no regressions, then merge. When the full codemod runs later, it must
not duplicate flags that were already set. The smart merge guarantees this: existing
flags (and their values, even intentional false) are preserved. Running the codemod
twice on the same file produces the same output as running it once.

Implementation

The second argument manipulation uses bracket-aware splitting rather than regex:

function splitArgs(content: string): string[] {
  // Tracks depth across (, {, [ and string literals
  // so the , inside { basename: '/app' } at depth=1
  // is never treated as an argument separator
}
Enter fullscreen mode Exit fullscreen mode

This correctly handles createBrowserRouter(getRoutes(), { basename: '/app' }) where
the first argument is itself a function call containing commas.


4. Automation Coverage Breakdown

Transform Category Automation rate What requires manual review
update-package Dependencies 100% None — mechanical swap
update-imports Imports 100% None — all cases handled including DOM split and aliases
add-future-flags JSX future flags 100% None — smart merge covers partial flags
update-data-router Data router flags ~95% createBrowserRouter wrapped in HOF or assigned dynamically
remove-json-defer Deprecated APIs ~85% json(data, { status }) two-argument form — status code must be moved manually
migrate-fallback Fallback migration ~70% HydrateFallback destination is always a route config, possibly in a different file
fix-form-method Silent runtime bugs ~95% Dynamically constructed method strings; type-asserted comparisons

Overall automation rate: ~85–90% of all mechanical migration changes.

The remaining 10–15% is not a gap in the codemod — it is work that is not safely
automatable
without understanding project-specific conventions. Automated transformation
of the wrong file with insufficient context would produce a false positive, which is worse
than leaving it for manual review.


5. Real-World Validation

Methodology

Each repository was cloned at its current main branch. The codemod was run against
the full repository. Results were reviewed with git diff to verify correctness. Any
false positive (correct code changed incorrectly) would count as a failure.

Repository 1 — remix-run/indie-stack

What it is: The official Remix TypeScript starter template. Uses React Router v6 with
the data router pattern throughout.

What was found: 35 files scanned, 5 modified, 0 false positives

Interesting findings:

  • All createBrowserRouter calls were in app/root.tsx and correctly received future flags
  • loader functions throughout used json() wrappers — all correctly stripped
  • Import paths were already partially split between @remix-run/react and react-router-dom; only the react-router-dom imports were touched

Repository 2 — alan2207/bulletproof-react

What it is: A heavily-cited React architecture boilerplate demonstrating best practices.
Uses React Router v6 with feature-based routing.

What was found: 425 files scanned, 2 modified, 0 false positives

Interesting findings:

  • Multiple createBrowserRouter calls across feature modules — each received flags independently
  • useNavigation().formMethod comparisons in form components — all correctly uppercased
  • NavLink as ActiveLink aliased imports — alias preserved intact through the import split

Repository 3 — marmelab/react-admin

What it is: A large-scale React framework for building admin interfaces. One of the
largest React Router v6 codebases publicly available.

What was found: 1,678 files scanned, 54 modified, 0 false positives

Interesting findings:

  • High density of json() and defer() in resource loader definitions — bulk removal worked correctly
  • Some json(data, { status: 404 }) two-argument calls were correctly skipped — the status code would have been silently dropped
  • Import specifier lists of 8–10 items per import — specifier ordering preserved

Repository 4 — novuhq/novu

What it is: A large-scale production notification infrastructure platform with a full
React frontend. One of the most heavily React Router-dependent open-source apps available,
with routing spread across dozens of feature modules.

What was found: 6,452 files scanned, 210 modified, 0 false positives

Interesting findings:

  • 210 files modified across a monorepo with deeply nested package structure — the walker correctly skipped node_modules and dist directories at every level
  • RouterProvider imports were consistently split to react-router/dom while all other hooks stayed on react-router
  • No false positives despite the large surface area — AST precision and early-exit guards held across all 6,452 files

Aggregate results

Repository Files scanned Files modified False positives
remix-run/indie-stack 35 5 0
alan2207/bulletproof-react 425 2 0
marmelab/react-admin 1,678 54 0
novuhq/novu 6,452 210 0
Total 8,590 271 0

6. Zero False Positives — How We Guarantee It

False positives — cases where the codemod changes correct code incorrectly — are worse
than false negatives. A missed transformation leaves the developer with a TODO. A false
positive silently introduces a bug.

Three mechanisms work together to guarantee zero false positives:

Mechanism 1 — AST matching, not regex

Every transform uses tsx.parse() from @ast-grep/napi to build a full TypeScript AST
before making any change. Changes are made by character position of matched AST nodes,
not by line-level pattern matching.

The practical consequence: a react-router-dom URL string inside a comment, a JSDoc
block, or a template literal is never touched. Regex matching from 'react-router-dom'
cannot distinguish these cases. AST matching does so inherently.

// This comment mentions react-router-dom — untouched ✅
// import { BrowserRouter } from 'react-router-dom' in a dead code comment — untouched ✅
const url = 'https://npmjs.com/package/react-router-dom' // untouched ✅
import { useNavigate } from 'react-router-dom' // transformed ✅
Enter fullscreen mode Exit fullscreen mode

For fix-form-method, the protection is even more precise. The 16-pattern rule
constrains matching to binary expressions where one side is specifically
navigation.formMethod or fetcher.formMethod:

// navigation.formMethod === "post" → transformed ✅
// someObj.formMethod === "post"    → untouched ✅  (not navigation or fetcher)
// navigation.state === "post"      → untouched ✅  (not formMethod)
// method === "post"                → untouched ✅  (no member expression)
Enter fullscreen mode Exit fullscreen mode

Mechanism 2 — Per-transform try/catch

Every transform wraps its entire logic in a try/catch that returns the original source
on any error:

export default function transform(fileInfo: { path: string; source: string }): string {
  if (!fileInfo.source.includes('react-router-dom')) return fileInfo.source

  try {
    // All AST parsing and manipulation
    return result
  } catch {
    return fileInfo.source  // ← original source, unchanged
  }
}
Enter fullscreen mode Exit fullscreen mode

An unexpected input that causes a parse error, an unusual TypeScript syntax that confuses
the AST, or any runtime exception in the transform logic produces zero output change
for that file. The transform fails open, not closed.

Mechanism 3 — Early exit optimisation

Each transform checks for a plain-string precondition before invoking the AST parser:

// update-imports.ts
// tsx.parse() is never called if the file has no react-router-dom import
const root = tsx.parse(fileInfo.source) // only reached if precondition passes
Enter fullscreen mode Exit fullscreen mode

This is not just a performance optimisation. It means a file that cannot possibly be
affected by a transform is never touched — the AST is never even built for it.

Alias safety in import removal

remove-json-defer only removes plain json and defer specifiers — not aliased imports
like json as rj. If the codemod stripped json as rj from the import without also
transforming the rj(data) call sites (which it cannot find because rj is not json),
the result would be a broken import and a false positive:

// This is correctly left UNTOUCHED — alias means call sites use 'rj', not 'json'
import { json as rj } from 'react-router'
export const loader = () => rj({ data })  // call site uses alias
Enter fullscreen mode Exit fullscreen mode

The check is DEPRECATED.has(s) where s is the full specifier string. 'json' is in
the set; 'json as rj' is not.


7. Manual Review Required

The codemod leaves TODO comments and skips certain patterns intentionally. This section
documents exactly what requires human judgment and why it cannot be safely automated.

fallbackElementHydrateFallback (transform 6)

The fallbackElement prop is removed from RouterProvider and a TODO comment is
inserted in its place:

{/* TODO: v7 migration — move fallbackElement to HydrateFallback on your root route.
    Add: { path: '/', Component: Root, HydrateFallback: LoadingSpinner } */}
<RouterProvider router={router} />
Enter fullscreen mode Exit fullscreen mode

Why not fully automated? HydrateFallback goes in the route configuration, not on
RouterProvider. In most apps, route configuration lives in a different file from the
RouterProvider render. Automatically finding the correct route and injecting
HydrateFallback into it would require understanding the project's routing architecture —
which file contains the root route, whether routes are co-located or centralised, whether
they use the object syntax or JSX routes. This is project-specific knowledge that a
general-purpose codemod cannot safely infer.

json(data, { status, headers }) — two-argument form (transform 5)

// This is intentionally SKIPPED
return json({ user }, { status: 404, headers: { 'Cache-Control': 'no-store' } })
Enter fullscreen mode Exit fullscreen mode

In v7, to return a response with a custom status code or headers, the pattern changes
from json(data, init) to returning a Response object directly:

// v7 manual fix
return new Response(JSON.stringify({ user }), {
  status: 404,
  headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }
})
Enter fullscreen mode Exit fullscreen mode

Automatically transforming json(data, init) to return data would silently discard
the status code and headers. This is a correctness issue — the transform skips
two-argument json() calls and leaves them unchanged. The developer will see a TypeScript
error on the json import after it is removed from the import specifiers, which serves
as a clear signal to address these call sites manually.

Complex nested route structures

The codemod injects future flags into createBrowserRouter(routes, opts) where routes
is the first argument. It does not inspect or modify the route configuration array itself.
Route configuration changes required by v7 — such as migrating from element to
Component, or restructuring nested routes to take advantage of improved splat path
resolution — require understanding the application's URL structure and are out of scope.

Custom history objects

React Router v6 allows <Router history={customHistory}> for non-standard environments
(Electron apps, React Native with custom navigation, server-side rendering with
createStaticRouter). These patterns are not transformed because:

  1. <Router> is not in the list of target components (only BrowserRouter, HashRouter, MemoryRouter, RouterProvider are targeted)
  2. The correct v7 equivalent depends on which environment the custom history is for

TypeScript loader and action type changes

React Router v7 changes the generic parameter signatures of useLoaderData,
useActionData, and related hooks. The v7 way is:

// v6
const data = useLoaderData<{ user: User }>()

// v7 — infer from loader function directly
const data = useLoaderData<typeof loader>()
Enter fullscreen mode Exit fullscreen mode

Automating this would require: finding each useLoaderData call, identifying which
loader function it corresponds to (by route configuration analysis), and rewriting
the generic parameter. This is a multi-file analysis problem that is beyond
single-file transform scope.


8. Migration Time Comparison

Manual migration of a 50-file codebase

A typical React Router v6 app with 50 files touching router code requires:

Task Time estimate
Read upgrade guide and understand scope 20–30 min
grep for all react-router-dom imports 5 min
Edit each import file (avg. 3 min × 20 files) 60 min
Find and update all createBrowserRouter calls 20–30 min
Find and remove all json() / defer() wrappers 30–45 min
Find all formMethod comparisons (easy to miss) 15–20 min
Handle fallbackElement props 15–20 min
Run TypeScript, fix type errors from missed cases 30–60 min
Manual testing to verify no runtime regressions 30–60 min
Total ~3.5–5 hours

This estimate assumes the developer has read the upgrade guide in full, knows about the
RouterProvider import path exception, and does not make any mistakes that require
backtracking.

With this codemod

npx codemod arpit2222/react-router-v6-to-v7   # ~20–30 seconds
git diff --stat                                 # review what changed
Enter fullscreen mode Exit fullscreen mode
Task Time
Run codemod ~30 seconds
Review git diff output 5–10 min
Address TODO comments (fallbackElement) 5–10 min per instance
Handle json(data, { status }) skipped calls 5 min per instance
Run TypeScript to confirm 2–5 min
Total ~20–30 minutes

Time saved per project

Project size Manual With codemod Time saved
Small (< 10 router files) 1–2 hours ~15 min ~1.5 hours
Medium (10–30 files) 3–5 hours ~25 min ~4 hours
Large (30–80 files) 6–10 hours ~35 min ~8 hours
Very large (80+ files) 2–3 days ~60 min ~2 days

The codemod's value scales with codebase size. In a large monorepo where React Router
is used across 50+ packages, manual migration becomes a multi-week coordination effort.
The codemod reduces it to a single PR.


Appendix — All 6 Future Flags Explained

Each flag maps to a specific behaviour change in v7:

Flag What it changes
v7_relativeSplatPath Fixes relative path resolution inside splat routes — paths like ../sibling now resolve correctly
v7_startTransition Wraps all navigation state updates in React.startTransition, preventing navigation from blocking rendering
v7_fetcherPersist Keeps fetcher data alive during revalidation — fetchers no longer reset while a parent loader is revalidating
v7_normalizeFormMethod formMethod on useNavigation() and useFetcher() returns uppercase ("POST") instead of lowercase ("post")
v7_partialHydration Enables partial hydration for server-rendered apps — routes can hydrate independently
v7_skipActionErrorRevalidation Skips automatic revalidation of all loaders when an action throws — only the affected route revalidates

All six must be set to true before upgrading to avoid breaking changes landing as
silent behaviour differences rather than hard errors.

Top comments (0)