React Router v8 Upgrade: Flags First, Then Bump

React Router v8 shipped in June 2026, and for most apps the upgrade is a dependency bump plus a few hours of cleanup. The work happens before the bump: turn on every future.v8_* flag while you are still on v7, fix what each one breaks, then install v8. Doing it in that order is the whole trick.

The new baselines are Node 22.22+, React 19.2.7+ and Vite 7+, and the packages are ESM only. The v7 to v8 upgrade guide lists them up front, because if you cannot hit those versions the rest of the post is academic.

One other thing worth knowing before you plan any of this. With v8 out, the team marked React Router v6 and Remix v2 as End of Life, so neither gets security updates any more. v7 still does.

What does the React Router v8 upgrade actually change?

Every future.v8_* flag from v7 is now the default behaviour: v8_middleware, v8_passThroughRequests, v8_trailingSlashAwareDataRequests and v8_viteEnvironmentApi. Split route modules graduated differently, moving from a future flag to a top-level splitRouteModules config option that is enabled by default.

The removals are short and specific. react-router-dom is gone as a re-export package. CommonJS builds are gone. The data property in meta APIs and useMatches() is replaced by loaderData. The @react-router/dev/vite/cloudflare dev proxy is gone, as is the useRequestContextDomainName option on the Architect request handler. The internal vite-node dependency went too, which is invisible unless you were pinning it.

On the other side of the ledger, RouterProvider’s onError prop and fetcher.reset() both stabilised.

React Router has also moved to a yearly major release cadence, with the stated aim of making majors “regular, predictable, and most of all boring”. That matters for planning more than any single API in the release. You now know roughly when the next one lands.

Why turn on the future flags before you bump the version?

Because a flag lets you take one breaking change at a time, on a version you can still ship, with a working test suite between each. Every breaking change in v8 that has a flag was already available in v7.9-plus, so the sequence is: get current on v7, enable one flag, fix, commit, repeat.

// react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  future: {
    v8_middleware: true,
    v8_splitRouteModules: true,
    v8_viteEnvironmentApi: true,
    v8_passThroughRequests: true,
    v8_trailingSlashAwareDataRequests: true,
  },
} satisfies Config;

Do not turn all five on in one commit, even though the file makes it look easy.

v8_splitRouteModules is free, an automatic build optimisation with no code changes. v8_viteEnvironmentApi needs Vite 7 and moves any custom SSR rollupOptions from the top-level build config into environments.ssr.build. If you have already been through a Vite 8 and Rolldown migration this will feel familiar.

v8_passThroughRequests gives loaders and actions a normalised url argument, so code that parsed request.url to work out whether it was handling a .data request needs a look. v8_trailingSlashAwareDataRequests changes the data request path for trailing-slash routes to a /_.data format, which is only interesting if you have CDN or cache rules matching on .data paths. Check those rules. A stale cache rule fails quietly.

What does middleware being on by default change?

This is the flag that costs real time. The React Router maintainers said as much in the v8 discussion: the changes to getLoadContext and AppLoadContext have the most potential to be disruptive.

With middleware on, context is no longer the plain object you returned from getLoadContext. It is a RouterContextProvider with get and set methods, keyed by context objects you create yourself.

// app/context.ts
import { createContext } from "react-router";
import type { User } from "~/types";

export const userContext = createContext<User | null>(null);

A server middleware receives the same arguments a loader does, plus a next function, and returns the response.

// app/routes/dashboard.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/dashboard";
import { userContext } from "~/context";

const authMiddleware: Route.MiddlewareFunction = async (
  { request, context },
  next,
) => {
  const user = await getUserFromSession(request);
  if (!user) throw redirect("/login");
  context.set(userContext, user);
  return next();
};

export const middleware: Route.MiddlewareFunction[] = [authMiddleware];

export async function loader({ context }: Route.LoaderArgs) {
  const user = context.get(userContext);
  return { profile: await getProfile(user) };
}

Client middleware has the same shape but returns nothing, since there is no response to hand back.

If you run a custom server, getLoadContext has to build and seed the provider instead of returning an object literal.

import { RouterContextProvider } from "react-router";
import { dbContext, createDb } from "./db";

function getLoadContext() {
  const context = new RouterContextProvider();
  context.set(dbContext, createDb());
  return context;
}

The upside is that the auth check, the request timing and the database handle stop being copied into the top of every loader. The middleware guide covers the data mode equivalent, getContext on createBrowserRouter, which follows the same pattern.

What breaks in v8 that no flag covers?

Four things, and they are all mechanical.

Uninstall react-router-dom and import from react-router, with the DOM-specific entries such as RouterProvider and HydratedRouter coming from react-router/dom. In meta functions and useMatches(), swap data for loaderData. On Cloudflare, replace the removed dev proxy with the official @cloudflare/vite-plugin in your Vite config. On Architect, the useRequestContextDomainName option is gone, and you can set it to true in v7 first so the behaviour change lands separately from the version bump.

export function meta({ loaderData, matches }: Route.MetaArgs) {
  const root = matches.find((match) => match.id === "root");
  return [{ title: `${loaderData.title} | ${root?.loaderData.siteTitle}` }];
}

The ESM-only build is the one that surprises people. Any server file, script or config that still does require("react-router") will stop working, and the error arrives at runtime rather than in the type checker. Grep for it before you bump, alongside the tsconfig work described in our TypeScript 7 migration notes.

What if you are still on Remix v2?

Then you have two upgrades, and Remix v2 no longer receives security fixes, so the first one is not optional for long.

Go to React Router v7 first. The Remix v2 upgrade guide has a codemod that handles the package renames, npx codemod remix/2/react-router/upgrade, which maps @remix-run/node to @react-router/node, @remix-run/react to plain react-router and so on. After that you update the package scripts to react-router dev, react-router build and react-router-serve, add app/routes.ts and react-router.config.ts, swap the remix Vite plugin for reactRouter(), and rename RemixServer to ServerRouter and RemixBrowser to HydratedRouter in your entry files.

Then, and only then, start on the flags.

Should you wait for Remix 3 instead?

No, not if you have a React app in production today.

The Remix 3 beta preview landed on 30 April 2026 and the post is blunt about its status: “This is still a pre-release. It is not production ready yet, and there is still a lot to do.” It is a different framework rather than the next version of yours, built on web platform primitives, shipping its own routing, request handling, sessions, forms, uploads and data layer. You start one with npx remix@next new my-remix-app. There is no migration path from React Router, because it is not that kind of release.

Evaluate it for a greenfield project in six months. Do not let it hold up a security-supported version bump on an app that is earning money.

What we would do

On a v7 app with tests, budget a day. Flags one at a time with a commit each, most of it going on middleware and getLoadContext, then the version bump, then the react-router-dom and loaderData cleanup that the type checker finds for you.

Two cases where we would leave it alone. If you are on Shopify Hydrogen, you are pinned to React Router 7 by the framework, with new projects scaffolding on 7.16 as of mid-2026, so wait for Shopify rather than fighting the peer dependency check. Our Hydrogen and Next.js comparison goes into what else that coupling costs you. And if you cannot get to React 19.2.7 and Node 22.22 this quarter, stay on v7, which is still getting security updates, and do the flags anyway so the eventual bump is boring.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch