Skip to content

React Router v6 to v7 Migration: Why Breaking Changes Keep Hurting and How to Survive

The Problem

I upgraded my React app and got hit with this:

ERROR in ./src/App.tsx
Module '"react-router-dom"' has no exported member 'BrowserRouter'.
Module '"react-router-dom"' has no exported member 'Switch'.

Wait, BrowserRouter is gone? After years of using React Router, the library I trusted for routing suddenly broke my app.

Let me check the changelog:

BREAKING CHANGES in v7:
- Remix routing patterns are now the default
- Component-based routing (BrowserRouter, Switch) deprecated
- Data router pattern required
- Future flags from v6 are now default behaviors

I was frustrated. Why do they keep making breaking changes?

The Context: Why React Router Keeps Breaking Things

After digging into the history, I found the root cause.

The Remix Merger

In 2022, Shopify acquired Remix. Then the Remix team merged their routing system into React Router v7. This is not just an upgrade - it is a paradigm shift:

Evolution Timeline
v6 (2021) v7 (2024)
| |
v v
Library Framework
| |
v v
Component Data Router
| |
v v
Client-only SSR-ready

The React Router team prioritized framework features over backward compatibility. Forward compatibility was sacrificed for Remix integration.

The Community Pain

I saw this on Reddit:

“Most recently, somewhat begrudgingly because these guys make breaking changes for fun”

“So many times I’ve been stung by painful migrations because they don’t realise forwards compat is a feature”

“React Router pretty much feels like abandonware at this point”

The frustration is real. Each major version requires significant migration effort, documentation lags behind, and simple TypeScript PRs languish unmerged.

How I Migrated: The Future Flags Approach

The key to a smooth migration is incremental adoption using future flags. Do not jump straight to v7.

Step 1: Stay on v6 and Enable Flags

First, ensure you are on the latest v6:

Terminal window
npm install react-router-dom@^6.22.0

Then enable future flags one by one:

src/router.js
import { createBrowserRouter } from 'react-router-dom'
const router = createBrowserRouter([
// your routes here
], {
future: {
v7_normalizeFormMethod: true, // Start with this
}
})

Run your tests. Fix any issues. Then add the next flag:

src/router.js
const router = createBrowserRouter(routes, {
future: {
v7_normalizeFormMethod: true,
v7_prependBasename: true, // Add this after tests pass
}
})

Continue this process until all flags are enabled:

src/router.js
const router = createBrowserRouter(routes, {
future: {
v7_normalizeFormMethod: true,
v7_prependBasename: true,
v7_relativeSplatPath: true,
v7_fetcherPersist: true,
v7_partialHydration: true,
v7_skipActionErrorRevalidation: true,
}
})

Step 2: Migrate to Data Router Pattern

v6 component-based routing still works but is deprecated. v7 requires the data router pattern.

Before (v6 style):

src/App.jsx (v6)
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/users">Users</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users" element={<Users />} />
<Route path="/users/:id" element={<UserDetail />} />
</Routes>
</BrowserRouter>
)
}

After (v7 style):

src/App.jsx (v7)
import { createBrowserRouter, RouterProvider, Link, Outlet } from 'react-router-dom'
const routes = [
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{
path: "users",
element: <Users />,
children: [
{ path: ":id", element: <UserDetail /> }
]
}
]
}
]
const router = createBrowserRouter(routes)
function App() {
return <RouterProvider router={router} />
}
function Layout() {
return (
<>
<nav>
<Link to="/">Home</Link>
<Link to="/users">Users</Link>
</nav>
<Outlet />
</>
)
}

Step 3: Move Data Fetching to Loaders

The biggest mental shift is moving from useEffect data fetching to loaders.

Before (v6 pattern):

src/pages/Users.jsx (v6)
function Users() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(data => {
setUsers(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
if (loading) return <Spinner />
if (error) return <Error message={error} />
return <UserGrid users={users} />
}

After (v7 pattern):

src/router.jsx (v7)
const routes = [
{
path: "/users",
element: <Users />,
loader: async () => {
const res = await fetch('/api/users')
if (!res.ok) throw new Error('Failed to load users')
return res.json()
},
errorElement: <ErrorBoundary />
}
]
src/pages/Users.jsx (v7)
import { useLoaderData, useNavigation } from 'react-router-dom'
function Users() {
const users = useLoaderData() // Data already loaded
const navigation = useNavigation()
if (navigation.state === "loading") {
return <Spinner />
}
return <UserGrid users={users} />
}

The benefits are immediate:

  • Parallel data loading for nested routes
  • Built-in error handling
  • Automatic caching and revalidation

Step 4: Handle Forms with Actions

Form handling changes dramatically.

Before (v6 pattern):

src/pages/CreatePost.jsx (v6)
function CreatePost() {
const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
setSubmitting(true)
const formData = new FormData(e.target)
try {
await createPost(formData)
navigate('/posts')
} catch (err) {
alert(err.message)
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<input name="title" />
<textarea name="content" />
<button disabled={submitting}>Submit</button>
</form>
)
}

After (v7 pattern):

src/router.jsx (v7)
import { redirect } from 'react-router-dom'
const routes = [
{
path: "/posts/new",
element: <CreatePost />,
action: async ({ request }) => {
const formData = await request.formData()
const post = await createPost(formData)
return redirect(`/posts/${post.id}`)
}
}
]
src/pages/CreatePost.jsx (v7)
import { Form, useNavigation } from 'react-router-dom'
function CreatePost() {
const navigation = useNavigation()
const isSubmitting = navigation.state === "submitting"
return (
<Form method="post">
<input name="title" />
<textarea name="content" />
<button disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Submit'}
</button>
</Form>
)
}

Step 5: Add Error Boundaries

v7 expects explicit error handling:

src/router.jsx
import { useRouteError, isRouteErrorResponse } from 'react-router-dom'
const routes = [
{
path: "/",
element: <Layout />,
errorElement: <RootErrorBoundary />,
children: [
// routes
]
}
]
function RootErrorBoundary() {
const error = useRouteError()
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
)
}
return <div>Something went wrong</div>
}

Step 6: Upgrade to v7

After all flags are enabled and tests pass:

Terminal window
npm install react-router-dom@^7.0.0

All future flags are now default. Remove them from your config:

src/router.jsx
// Flags are no longer needed in v7
const router = createBrowserRouter(routes)

Common Mistakes I Made

Mistake 1: Jumping Straight to v7

I tried to upgrade directly. The app exploded with cryptic errors.

Wrong:

Terminal window
npm install react-router-dom@latest
# 500+ type errors, broken routing

Right:

Terminal window
# Stay on v6, enable flags first
npm install react-router-dom@^6.22.0
# Enable all flags, test, then upgrade
npm install react-router-dom@^7.0.0

Mistake 2: Mixing Router Patterns

I tried to nest old and new routers together.

Wrong:

src/App.jsx
function App() {
return (
<RouterProvider router={router}>
<BrowserRouter> {/* Do not do this */}
<Routes>...</Routes>
</BrowserRouter>
</RouterProvider>
)
}

Right:

src/App.jsx
function App() {
return <RouterProvider router={router} />
}

Mistake 3: Forgetting Pending States

The first time users navigate, data needs to load. I forgot loading states.

Wrong:

src/pages/Users.jsx
function Users() {
const users = useLoaderData()
return <UserGrid users={users} /> // Crashes if data not ready
}

Right:

src/pages/Users.jsx
function Users() {
const users = useLoaderData()
const navigation = useNavigation()
if (navigation.state === "loading") {
return <Spinner />
}
return <UserGrid users={users} />
}

Mistake 4: Not Using defer for Slow Data

Some data loads slowly. Use defer for progressive loading:

src/router.jsx
import { defer } from 'react-router-dom'
const routes = [
{
path: "/dashboard",
element: <Dashboard />,
loader: async () => {
// Fast data first
const user = await fetchUser()
// Slow data deferred
return defer({
user,
analytics: fetchAnalytics() // Promise, not awaited
})
}
}
]
src/pages/Dashboard.jsx
import { useLoaderData, Await } from 'react-router-dom'
import { Suspense } from 'react'
function Dashboard() {
const { user, analytics } = useLoaderData()
return (
<div>
<h1>Welcome, {user.name}</h1>
<Suspense fallback={<Spinner />}>
<Await resolve={analytics}>
{(data) => <AnalyticsChart data={data} />}
</Await>
</Suspense>
</div>
)
}

Migration Timeline

For a medium-sized project (20-30 routes), I budgeted:

Migration Timeline
Week 1: Enable future flags (2-3 days)
- Day 1-2: Enable v7_normalizeFormMethod, v7_prependBasename
- Day 3: Enable remaining flags, fix issues
Week 2: Convert to data router pattern (3-4 days)
- Day 1: Restructure routes
- Day 2-3: Convert loaders/actions
- Day 4: Error boundaries
Week 3: Testing and cleanup (2-3 days)
- Day 1-2: E2E testing
- Day 3: Upgrade to v7, final tests

Should You Migrate?

Consider the alternatives:

OptionProsCons
Stay on v6No migration costEventually unsupported
Migrate to v7Long-term support, new featuresMigration effort
TanStack RouterModern, better DXAnother migration

If your project is small or you are starting fresh, TanStack Router is worth considering. For existing v6 projects, the future flags approach makes migration manageable.

Summary

React Router’s breaking changes stem from the Remix merger. The team chose framework features over backward compatibility. But with future flags, you can migrate incrementally:

  1. Stay on v6.22+
  2. Enable future flags one by one
  3. Convert to data router pattern
  4. Move data fetching to loaders
  5. Handle forms with actions
  6. Add error boundaries
  7. Upgrade to v7

The migration is painful, but the result is a more powerful routing system with built-in data loading, error handling, and SSR support.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments