React Router v8 Upgrade Cost Two Days — Here’s What Actually Broke
Yesterday I saw React Router released v8.3.1. My project was still on a mid-range v7 version, and since there were no scheduled requirements for the weekend, I figured I might as well try upgrading.
The changelog was optimistic: "all of them are changes you can make in v7," meaning all breaking changes could be adapted ahead of time via future flags. That seemed reasonable to me — our project had been on v7 for almost half a year, so all the relevant flags should already be enabled.
After running pnpm i react-router@latest and starting the project, three things blew up immediately.
The upgrade itself isn't hard, but the official docs calling it a "boring release" is a bit misleading. For a package with 50 million+ weekly npm downloads, the word "boring" makes it easy to let your guard down.
react-router-dom is gone — package name change
This was the first error. react-router-dom has been completely removed in v8.
Every import from "react-router-dom" in the project turned red. Fortunately, the change is purely mechanical:
// v7
import { BrowserRouter, Routes, Route, Link, useNavigate } from "react-router-dom";
// v8
import { BrowserRouter, Routes, Route, Link, useNavigate } from "react-router";
For DOM-specific APIs (like <ScrollRestoration />), you need to import from react-router/dom:
import { ScrollRestoration } from "react-router/dom";
I wrote a script to do a global find-and-replace for this step — about 40 files changed, took less than ten minutes.
But there's a catch here. We had a third-party UI library that still uses react-router-dom internally, and it threw a Module not found error directly. After digging around, I found its peerDependencies hadn't been updated. In the end, I had to manually add an alias in package.json to redirect it to react-router as a temporary workaround.
// Add resolve alias in vite.config.ts
{
"resolve": {
"alias": {
"react-router-dom": "react-router"
}
}
}
If your project also depends on other packages that still use react-router-dom, check them ahead of time.
Middleware — this is the real tough part
While the package name change throws errors, at least the error messages are clear and you can fix them. The middleware issue is the kind where "the project runs, but the behavior is wrong" — that's the most time-consuming type to troubleshoot.
In v8, the v8_middleware future flag has been removed, and middleware is now the default behavior. Our project hadn't enabled this flag in v7, so middleware was never configured.
After upgrading, the first time I accessed a protected page, it redirected straight to the login page — not the behavior I expected.
It took a while to figure out. v8 enables the middleware pipeline by default, but our auth logic was previously written inside loaders. The loader executed an unauthenticated route load before the middleware pipeline, and then the middleware ran its own auth check — the two logics were fighting each other. The loader got an empty token, and then the middleware intercepted and redirected, resulting in the page flashing once before jumping away. The solution is to extract auth out of loaders and put it into the middleware layer:
// app/middleware/auth.ts
import type { Middleware } from "react-router";
export const authMiddleware: Middleware = async ({ request, context }) => {
const url = new URL(request.url);
// Whitelist routes pass through directly
const publicPaths = ["/login", "/register", "/public"];
if (publicPaths.some(p => url.pathname.startsWith(p))) {
return;
}
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
throw new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
// Verify token and store in context so subsequent loaders can use it directly
try {
const user = await verifyToken(token);
context.set("user", user);
} catch {
throw new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
};
Then register it in the route config:
// app/routes.ts
import { authMiddleware } from "./middleware/auth";
export const routes = [
{
path: "/",
middleware: [authMiddleware],
children: [
{ index: true, file: "./routes/dashboard.tsx" },
{ path: "settings", file: "./routes/settings.tsx" },
// ...
],
},
{ path: "/login", file: "./routes/login.tsx" },
];
Loaders are now clean, just grabbing user info from context:
// routes/dashboard.tsx
export async function loader({ context }: LoaderFunctionArgs) {
const user = context.get("user");
return { userName: user.name, role: user.role };
}
After making these changes, I breathed a sigh of relief and clicked through several pages locally — no issues. I thought, "This upgrade isn't as scary as people online say." Then I deployed to the test environment, and CI failed.
Node version issue: CI was still on 20
v8 requires Node 22.22.0+, React 19.2.7+, and Vite 7+. Our CI was running Node 20 LTS. The local dev environment had already been switched to 22, but the CI config file had the version hardcoded. This change itself isn't big — just modifying the GitHub Actions workflow:
# .github/workflows/ci.yml
- uses: actions/setup-node@v4
with:
node-version: "22.22" # changed from "20" to "22.22"
cache: "pnpm"
After that change, there was a second problem — the Docker base image. Our test containers were using node:20-slim, which also needed to be swapped. After switching to node:22-slim, a full test run passed.
But there's a detail worth noting. In Node 22, the default behavior of --experimental-webstorage changed, and one of our utility libraries that depends on localStorage threw errors directly in the test environment. We had to add NODE_OPTIONS=--no-experimental-webstorage to work around it. If your test cases have logic that mocks localStorage or sessionStorage, run through them after upgrading to Node 22.
At this point, the technical issues were resolved, but there was an even more hidden trap.
We had a pre-rendered static page that used the v8_passThroughRequests flag. In v8, this flag was removed and became the default behavior. In theory, it should have been a seamless transition, but when actually running pre-rendering, the loader data for some pages wasn't being correctly serialized into the HTML.
Checking the v8.3.0 changelog revealed this was a known issue, fixed in v8.3.1 (the version released just yesterday). So if you also encounter pre-rendering problems, make sure to upgrade to v8.3.1+.
ESM-only: we didn't hit this, but saw plenty of others who did
Our project uses Vite entirely and went full ESM two years ago, so this step was basically problem-free.
But v8's ESM-only requirement is devastating for older projects. tsconfig's target and lib are forced to ES2022, and projects still using require() or writing CommonJS syntax in jest.config.js simply won't run. Several people on the Juejin community complained that this step alone took them a full day, especially the old combination of webpack 4 + Jest 27.
If you fall into this category, it's recommended to upgrade your build toolchain first — Webpack 5+ or Vite 7+, Jest 29+ or switch to Vitest — before touching React Router. This prerequisite workload is about the same as upgrading to v8 itself, but once done, subsequent upgrades will be smooth.
Some other scattered pitfalls
splitRouteModules became a top-level config in v8 and is enabled by default. Our project's route modules were already very small, so I didn't notice any obvious change. But if you find that your build output got larger after upgrading or the code-splitting behavior is different from before, check this config.
A colleague's project ran into the deprecated data parameter issue. v8 changed the loader's data parameter to loaderData, and the meta API also had to follow suit. His project had a custom useRouteData hook that directly referenced the old data field, and it took a global search across a dozen places to fix.
Some API import paths in react-router/dom also changed. If your project uses <ScrollRestoration> or <Form>, make sure to import from react-router/dom rather than react-router. This change isn't big, but IDEs won't auto-suggest it — you have to check each one manually.
Additionally, v6 and Remix v2 are officially EOL and will no longer receive security updates. If your project is still on these versions, this upgrade isn't a question of "do you want to" — it's "you must." React Router officially stated that subsequent security patches will only be applied to v7 and v8. There's also something worth mentioning: some developers have already started migrating to TanStack Router. After v8 was released, someone on Reddit posted complaining about the breaking changes, and quite a few commenters said they'd take the opportunity to switch. TanStack Router's end-to-end type safety and built-in stale-while-revalidate caching are indeed nice, but if your project already deeply uses React Router's loader/action pattern, the cost of switching routing libraries is far higher than upgrading versions.
How long it took
Package name change + fixing import paths: half an hour. Middleware refactoring + auth logic migration: most of a day. CI Docker image + Node version: 1 hour. Pre-rendering issue troubleshooting (discovering it was fixed in v8.3.1): half a day. Regression testing + manually clicking through pages: 2 hours. Two days — much longer than the half-day I expected.
Looking back at the official line "it's not a major version if nothing broke" — it's indeed correct. But for a project with real business logic, "nothing broke" and "smooth to change" are two different things. The middleware migration isn't complex, but it forces you to rethink which layer your auth logic belongs in — that kind of architectural adjustment was never going to be done in half an hour.
If your project is also preparing to upgrade, a few suggestions:
First, run pnpm i react-router@latest locally and see how many errors you get. If it's just import path issues, half a day will do it. If it involves middleware and ESM compatibility, reserve two days. Also, go straight to v8.3.1 — don't stop at an earlier version; the pre-rendering bug was just fixed.