EmpireUI
Get Pro
← Blog9 min read#react router v7#routing#navigation

React Router v7: What Changed, What Broke and What to Migrate

React Router v7 ships framework-mode routing, flattened loaders, and dropped legacy APIs. Here's what actually broke and how to migrate without losing your mind.

Abstract code navigation arrows on a dark terminal screen background

Why v7 Is a Bigger Deal Than You Think

React Router v7, released in late 2024 and now the stable default going into 2026, isn't just a patch bump. It's a merger. The Remix team officially folded Remix's framework conventions — loaders, actions, file-based routing, server-side data fetching — directly into React Router. So if you've been ignoring it because "routing didn't seem broken," you're now missing a full data-layer overhaul.

Honestly, the versioning is what caused most of the confusion. You had Remix v2, React Router v6, and suddenly React Router v7 ships with a framework mode that does what Remix used to do. That's a real identity shift. The library didn't just add features — it absorbed an entire meta-framework.

That said, the library mode (plain client-side routing, no server stuff) still works the same way it always did. If you're just doing <BrowserRouter> with nested <Route> components, your code won't explode. But you will hit deprecation warnings for a handful of APIs, and the upgrade path for anything touching data loading is non-trivial.

Worth noting: the docs split the product into three modes — Declarative, Data, and Framework. Most existing apps are in Declarative mode. Most v7 content you'll find online is about Framework mode. These are genuinely different things, and conflating them is how teams waste a weekend.

If you're building a fresh UI and want to pair v7 routing with a polished component set, the Empire UI library has templates that already account for the new loader pattern. Makes sense to start from something that isn't fighting the migration at the same time as your routing.

What Actually Changed in the API Surface

The biggest surface change: <Route> now has a first-class loader and action prop in Data mode, wired directly to createBrowserRouter. This isn't new if you came from Remix, but it replaces a lot of useEffect-based fetch patterns that were standard in React Router v5 and v6.

import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/dashboard',
    element: <Dashboard />,
    loader: async ({ request }) => {
      const res = await fetch('/api/dashboard', { signal: request.signal });
      return res.json();
    },
  },
]);

export default function App() {
  return <RouterProvider router={router} />;
}

Inside the component, you pull that data with useLoaderData(). No useEffect, no local loading state, no manual useState for isLoading. The router owns the fetch lifecycle. This is genuinely better — but it means your components need to trust the router, and any component that was self-fetching needs to be refactored.

In practice, the errorElement prop replaced the old <ErrorBoundary> pattern at the route level. Each route can now declare its own error UI, which is a massive improvement over catching everything at the root. You'd previously need a third-party wrapper or manual propagation to get per-route error handling.

One more thing — useNavigate now returns a typed function if you're using the new TypeScript-first codegen (introduced in 7.1). The old navigate('/foo') still works, but navigate({ to: '/foo' }) gives you autocomplete on paths if you've generated route types. It's optional, but once you've used it in a medium-sized app you won't go back.

What Actually Broke (the Migration Pain Points)

Let's be specific about what breaks. <Switch> was removed in v6 — if you're jumping from v5 directly to v7, that's the first wall. Replace every <Switch> with <Routes>, and every <Route exact path=... component=...> with <Route path=... element={<Component />}>. This is mechanical, but in a large app with 80+ routes it takes a full afternoon.

// v5 (broken in v7)
<Switch>
  <Route exact path="/" component={Home} />
  <Route path="/about" component={About} />
</Switch>

// v7 equivalent
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/about" element={<About />} />
</Routes>

The useHistory hook is gone. Fully. Not deprecated — gone. You need useNavigate. And history.push('/path') becomes navigate('/path'). If you have utility functions that accepted a history object and called .push(), those need to be refactored to use the hook inside components or accept a navigate function as an argument.

withRouter is also dead. It was a HOC from the class component era. Any component wrapped in withRouter to get match, location, or history as props needs to be converted to a function component using useParams, useLocation, and useNavigate respectively. Quick aside: this conversion is usually a net positive — the hooks version is cleaner — but it's not zero effort.

The Redirect component is now Navigate. Same behavior, different name. <Redirect to="/login" /> becomes <Navigate to="/login" replace />. Small change, easy to grep-and-replace, but it will absolutely break your build if you forget it. Run grep -r "from 'react-router-dom'" src/ | grep Redirect before you claim you're done.

Migrating from v6 to v7: A Practical Checklist

If you're already on v6, the jump to v7 is much smaller. The breaking changes between v6 and v7 are mostly in the Framework mode plumbing. But there are a few things that will silently change behavior if you're not paying attention in 2025–2026 projects.

First, update the package. Note that React Router v7 ships as react-router — not react-router-dom. The react-router-dom package still exists as a re-export layer for browser environments, but the canonical import source is now react-router. Update your package.json to "react-router": "^7.0.0" and audit your imports: ``bash # Find all react-router-dom imports grep -r "react-router-dom" src/ --include='*.tsx' --include='*.ts' ``

Second, if you're using <BrowserRouter> with v6-style <Routes>, you're in Declarative mode and you can stay there. Nothing forces you into Framework mode. But if you want loaders and actions, you need to switch to createBrowserRouter + RouterProvider. These two approaches don't mix — you can't bolt a loader onto a route inside <BrowserRouter>.

Third, relative path resolution changed slightly in v7. Routes that start with . now resolve relative to the current route's URL segment, not the full path. If you have relative <Link to="./edit"> components inside deeply nested routes, test them explicitly — some will resolve differently than in v6.2.x.

A working migration order that's saved me hours: (1) bump the package, (2) fix TS errors from removed types, (3) grep for removed APIs, (4) convert useHistoryuseNavigate and withRouter → hooks, (5) run your test suite, (6) manually test any route that uses relative links. Don't try to adopt loaders or Framework mode in the same PR. Separate concerns.

Framework Mode: Should You Use It?

Framework mode gives you file-based routing, server-side loaders, server-side actions, streaming with <Suspense>, and a built-in convention for code splitting. It's basically Remix v3 running inside React Router. The question is whether you actually need all of that.

Look, if you're building a new app and you want SSR or SSG, Framework mode is a solid choice. It's more opinionated than plain React Router but way lighter than Next.js. You get file-based routing under a app/routes/ directory and loaders that run on the server before the HTML is sent. If your team already knows Remix, this is a zero-learning-curve upgrade.

If you're maintaining a client-side SPA that talks to a REST API, Framework mode is probably overkill. The added complexity of server-side rendering brings little return if your data layer is already handled by React Query or SWR on the client. Stick to Data mode (createBrowserRouter with client-side loaders) and call it done.

One pattern worth stealing from Framework mode even if you don't go full server: the shouldRevalidate function on loaders. It controls when React Router re-fetches route data on navigation. You can use this in Data mode too. Without it, every navigation re-triggers every loader in the matched route tree — which burns API quota fast on busy pages. ``tsx { path: '/products/:id', loader: fetchProduct, shouldRevalidate: ({ currentParams, nextParams }) => { return currentParams.id !== nextParams.id; }, } ``

For apps that need dynamic UI with heavy visual work — think interactive dashboards with gradient generator panels or motion-heavy components — pairing v7's data layer with a well-structured component library cuts down on the boilerplate significantly. The routing just handles data; the UI layer handles the rest.

TypeScript Integration and the New Route Types

React Router v7.1 introduced automatic route type generation. Run react-router typegen and it generates a .react-router/types/ directory with per-route TypeScript interfaces. Your useLoaderData() call becomes typed automatically — no casting, no as, no hoping the shape matches.

# Add to your package.json scripts
"typegen": "react-router typegen"

# Then in your route file:
import type { Route } from './+types/dashboard';

export async function loader({ params }: Route.LoaderArgs) {
  return { user: await getUser(params.id) };
}

export default function Dashboard({ loaderData }: Route.ComponentProps) {
  // loaderData.user is fully typed — no cast needed
  return <h1>{loaderData.user.name}</h1>;
}

This is genuinely one of the best DX improvements in the v7 cycle. Previously you'd either as-cast everything or maintain a separate types file that drifted out of sync with your actual loader return shape. The codegen keeps them in lockstep. Worth adding typegen as a pre-build step in CI.

The catch: typegen only works in Framework mode with file-based routes. If you're in Data mode with a createBrowserRouter config object, you don't get this automatically. You'll need to manually type your useLoaderData calls with a generic: useLoaderData<typeof loader>(). Still way better than nothing, but not as ergonomic.

If your codebase is using the nextjs-app-router-guide pattern and you're evaluating whether React Router's Framework mode competes — the honest answer is: for pure SPAs with some server needs, v7 Framework mode is lighter. For anything needing the App Router ecosystem (server components, edge runtime, image optimization), Next.js still wins.

Common Gotchas After Migration

After shipping migrations on three separate production apps this year, here's the non-obvious stuff that bit us. First: fetcher.submit() changed its content-type defaults. In v6 it defaulted to application/x-www-form-urlencoded. In v7 it defaults to the same, but if your server action expects JSON and you pass an object, you now need to explicitly set encType: 'application/json'. Several devs burned hours on 415 errors before catching this.

Second: scroll restoration. React Router v7 ships a <ScrollRestoration /> component that works well for most cases, but it fires before lazy-loaded route chunks finish evaluating. If your routes are code-split and the restored scroll position fires at 0px height, you need to add a getKey prop that ties scroll position to the route pathname rather than the full URL. This is a 5-minute fix once you know it exists.

Third, pending navigation state moved. useTransition (the router one, not React 18's useTransition) is now useNavigation. The old name still re-exports in react-router-dom for a few minor versions, but useNavigation() is what you want. And the state shape changed — navigation.state is now 'idle' | 'loading' | 'submitting' instead of the v5 boolean flags.

Quick aside: if you build UIs where navigation transitions need to feel smooth — loading skeletons, page-exit animations, progress bars — check out how Empire UI's templates handle the useNavigation pending state. Wiring a 2px progress bar to navigation.state === 'loading' takes about 10 lines and makes your app feel dramatically more responsive. Users notice the 300ms feedback even if they can't articulate why.

Last one: <Link> reloadDocument prop. Some apps used this as an escape hatch to force a full page reload on certain links. It still works in v7, but if you're in Framework mode, server-side loaders mean you might not need it anymore. Audit your reloadDocument usage — some of those were workarounds for data-staleness problems that v7 loaders solve natively.

FAQ

Is React Router v7 the same as Remix?

Not exactly, but close in Framework mode. v7 absorbed Remix's conventions — loaders, actions, file-based routes — but you can use v7 without any of that in Declarative or Data mode. Think of it as Remix's DNA inside a more flexible host.

Do I need to rewrite my v6 app to use v7?

No. If you're using <BrowserRouter> with <Routes>, the migration is mostly a package update and a few deprecated API swaps. You only touch loaders and Framework mode if you choose to adopt them.

What happened to react-router-dom?

It still exists as a compatibility re-export for browser environments, but the main package is now just react-router. Import from there going forward — react-router-dom will likely be phased out in a future major.

Can I mix client-side loaders with server-side loaders?

Not in the same route tree. Client-side loaders live in Data mode (createBrowserRouter), server-side loaders live in Framework mode. Pick one model per app — mixing them is not supported and will cause confusing runtime behavior.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Router v7 Guide: File-Based Routing, Loaders, ActionsFile-Based Routing in React: Next.js App Router vs TanStack RouterTanStack Router vs React Router v7: File-Based Routing ComparedNext.js vs Remix in 2026: Which One Should You Use?