Upgrading React Router v7 to v8 comes down to five future flags, a handful of code changes, one install and one config cleanup. The official guide covers most of it. What it leaves out fails quietly: on our test app, react-router build succeeded at three points where the server then broke. One was a 500 on every request, one a crash on startup, and the last a 500 from a package the guide tells you to remove.

This runbook is for framework-mode apps on any 7.x release, served by react-router-serve or by a custom Express server. React Router 8.0.0 shipped on June 17, 2026; the current release is 8.5.1. We did not test Data or Declarative mode; the few notes for them below come straight from the official guide.

Tested on macOS 27.0.1 (arm64) with Node.js 24.22.1 and npm 11.19.1, react-router 7.16.0 -> 7.18.5 -> 8.5.1, Vite 7.3.7, React 19.3.1, November 2026, on the official node-custom-server and default templates, the first rewritten to a pre-flag v7 state.

Prerequisites

React Router v8 raises three floors. Update these first, on v7, and ship them on their own:

  • Node.js 22.22 or later. The react-router 8.5.1 package declares "node": ">=22.22.0".
  • React and React DOM 19.2.7 or later.
  • Vite 7 or later for framework mode. @react-router/dev 8.5.1 accepts Vite 7 or 8 as a peer, so this upgrade does not force you onto the Rolldown-based Vite 8. We stayed on Vite 7.3.7.
  • TypeScript 5.1, 6 or 7. The 8.5.1 peer range includes 7, if you are also working through the TypeScript 7 migration.
  • A typecheck script. The templates ship react-router typegen && tsc as npm run typecheck. Run it after every step: four of the failures below pass the build and only show up in typecheck or in the running server.
  • Clean git tree plus a staging environment: the docs recommend one commit per step, shipped before the next.
node --version
npm ls react react-dom vite

Steps 2 to 6 each turn on one flag. This is what each one did on our 7.18.5 app when we enabled it before making its code change:

FlagCode changeWhat we saw without it
v8_middlewaregetLoadContext returns a RouterContextProvider (custom servers only)Build passes, every request returns 500
v8_viteEnvironmentApiCustom SSR entry moves to environments.ssrBuild passes, the server crashes on start
v8_passThroughRequestsLoaders read url, not request.urlLoader sees /_root.data as its path
v8_trailingSlashAwareDataRequestsCDN and rewrite rules learn _.data/_root.data returns 404
v8_splitRouteModulesNoneNo change in our tests

Step 1. Update to the latest React Router 7.x

The docs say to start from the latest 7.x so you have access to the latest flags. That is 7.18.5, released October 27, 2026. Their command is below, where etc. stands for whichever adapter packages you use:

npm install react-router@7 @react-router/{dev,node,etc.}@7

For a custom Express server that still has react-router-dom, that becomes:

npm install react-router@7 react-router-dom@7 @react-router/{dev,node,express}@7

You should see npm run build print five warnings. These are their headlines, in the order we got them; each is followed by the flag to set and a docs link:

⚠️  Future Flag Warning: Route middleware support is changing in React Router v8.
⚠️  Future Flag Warning: Route module splitting behavior is changing in React Router v8.
⚠️  Future Flag Warning: Vite Environment API usage is changing in React Router v8.
⚠️  Future Flag Warning: Request handling behavior is changing in React Router v8.
⚠️  Future Flag Warning: Data request URL formats are changing in React Router v8.

If npm stops with npm error code ERESOLVE and Conflicting peer dependency: [email protected], you hit what we hit on npm 11.19.1 with an existing lockfile. Bumping the ranges in package.json or deleting node_modules did not help. Re-running the same command once with --legacy-peer-deps did, and a plain npm install right after it reported up to date. npm's docs say that flag "will not enforce the peerDependencies contract", so use it for this one install only and confirm the tree with npm ls (no invalid lines). Deleting package-lock.json and reinstalling also worked, but it re-resolves every dependency in the app, React Router and everything else. We did not test pnpm, Yarn or Bun.

Step 2. Enable v8_middleware and return a RouterContextProvider

Middleware is always on in v8, and with it the context argument becomes a RouterContextProvider instance. Turn the flag on in react-router.config.ts:

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

export default {
  future: {
    v8_middleware: true,
  },
} satisfies Config;

If you use react-router-serve, that is the whole step. Custom servers have more to do, because getLoadContext must now return a provider instead of a plain object: replace each AppLoadContext field with a typed context and read it with context.get. In app/context.ts:

import { createContext } from "react-router";

export const valueFromExpressContext = createContext<string>();

In server/app.ts, following the official node-custom-server template:

import { RouterContextProvider } from "react-router";
import { createRequestHandler } from "@react-router/express";
import express from "express";
import { valueFromExpressContext } from "../app/context";

export const app = express();

app.use(
  createRequestHandler({
    build: () => import("virtual:react-router/server-build"),
    getLoadContext() {
      const context = new RouterContextProvider();
      context.set(valueFromExpressContext, "Hello from Express");
      return context;
    },
  }),
);

And in each loader or action that read the old field:

export function loader({ context }: Route.LoaderArgs) {
  return { message: context.get(valueFromExpressContext) };
}

Pages should render as before. Skip the getLoadContext change and the build still succeeds, but every request returns a 500 while the server logs:

Error: Invalid `context` value provided to `handleRequest`. When middleware is enabled you must return an instance of `RouterContextProvider` from your `getLoadContext` function.

Run typecheck before the build and you catch it earlier, as Type '() => { VALUE_FROM_EXPRESS: string; }' is not assignable to type 'GetLoadContextFunction'. The v7 middleware docs also describe a shortcut: augment RouterContextProvider instead of AppLoadContext and Object.assign your old object onto a new provider. It worked on 7.18.5, but on 8.5.1 typecheck failed again with Property 'VALUE_FROM_EXPRESS' does not exist on type 'Readonly<RouterContextProvider>', and the docs warn it is not safe to assume it works in the next major, so skip the shortcut and migrate to typed contexts on v7. Data mode apps on v7 also add the Future module augmentation from the middleware docs so context is typed; v8 no longer needs it, so delete it after Step 9.

Step 3. Enable v8_viteEnvironmentApi and move your SSR build input

v8 always builds through the Vite Environment API. Add v8_viteEnvironmentApi: true to the future block. A custom server whose Vite config branches on isSsrBuild has to move its server entry under environments.ssr:

-export default defineConfig(({ isSsrBuild }) => ({
-  build: {
-    rollupOptions: isSsrBuild
-      ? { input: "./server/app.ts" }
-      : undefined,
-  },
-  plugins: [reactRouter()],
-}));
+export default defineConfig({
+  environments: {
+    ssr: {
+      build: {
+        rollupOptions: {
+          input: "./server/app.ts",
+        },
+      },
+    },
+  },
+  plugins: [reactRouter()],
+});

You should see build/server/assets/server-build-<hash>.js in the build output and node server.js serving pages. With the flag on and the old config left in place, the build still exited 0, the output had no server-build-<hash>.js file, and the production server died on start with:

TypeError: app.use() requires a middleware function

Apps on react-router-serve without custom Vite config need no change.

Step 4. Enable v8_passThroughRequests and read url instead of request.url

v7 normalizes the request.url passed to loaders, actions and middleware, stripping .data suffixes and internal search parameters like ?index. v8 passes the raw HTTP request instead. Add v8_passThroughRequests: true, then switch any URL inspection to the new url argument, which has React Router's internal details removed:

-export function loader({ context, request }: Route.LoaderArgs) {
-  const url = new URL(request.url);
+export function loader({ context, url }: Route.LoaderArgs) {
   return { path: url.pathname };
 }

Our loader returned its pathname, which makes the change easy to see. With the flag on and the old code, a client-side data request reported "path","/_root.data"; after switching to url it reported "path","/" again. Nothing errors here, so search your loaders, actions and middleware for request.url before you ship this step.

Step 5. Enable v8_trailingSlashAwareDataRequests and update CDN rules

This flag changes the URLs your client fetches data from. The root data request moves from /_root.data to /_.data, and a URL with a trailing slash such as /a/b/c/ now fetches /a/b/c/_.data. Add v8_trailingSlashAwareDataRequests: true and check both paths against your server:

curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/_.data
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/_root.data

You should see 200 then 404. Before you deploy, update any CDN, cache or rewrite rule that matches .data URLs. This is the step to roll out on its own and watch: browsers holding the new client bundle ask for /_.data, and Rollback below depends on this flag staying on.

Step 6. Enable v8_splitRouteModules

This splits clientLoader, clientAction, clientMiddleware and HydrateFallback into their own chunks, and it is on by default in v8. Add v8_splitRouteModules: true. No code changes are needed. Your config should now hold all five flags:

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

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

You should see npm run build print no Future Flag Warning lines. If you would rather not split, v8 has a top-level splitRouteModules: false after the upgrade to keep single chunks, and "enforce" turns any route that cannot be split because of shared code into a build failure.

Step 7. Replace meta data with loaderData and remove react-router-dom

Two removals need code changes in every mode. First, meta functions, matches[i] and useMatches() lose their data field:

-export function meta({ data }: Route.MetaArgs) {
-  return [{ title: data.title }];
+export function meta({ loaderData }: Route.MetaArgs) {
+  return [{ title: loaderData.title }];
 }

On 8.5.1, leaving data in place still built. The page rendered <title>Home - undefined</title> and only typecheck complained, with Property 'data' does not exist on type 'CreateMetaArgs<...>'. Second, move every react-router-dom import. RouterProvider and HydratedRouter come from react-router/dom; everything else comes from react-router:

grep -rn "react-router-dom" app server
npm uninstall react-router-dom

You should see the grep return nothing before you uninstall. Do not skip the uninstall: react-router-dom's latest tag on npm is still 7.18.5, so npm install react-router-dom@latest next to v8 quietly installs a second copy of react-router. We tried it on 8.5.1: typecheck passed, the build passed, and the server returned a 500 on every page with TypeError: Cannot destructure property 'basename' of 'React10.useContext(...)' as it is null. A GitHub issue reports the client-side version of the same mismatch as a blank page with nothing in the console.

Step 8. Swap the Cloudflare or Architect adapter (if you use one)

Skip this step on Node. v8 removes the Cloudflare dev proxy (@react-router/dev/vite/cloudflare) and drops wrangler@3 as a peer. On an edge runtime like Workers, switch to @cloudflare/vite-plugin while still on v7:

import { reactRouter } from "@react-router/dev/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    cloudflare(),
    reactRouter(),
  ],
});

For @react-router/architect, pass useRequestContextDomainName: true to createRequestHandler on v7, then delete it after the upgrade because v8 removes the option and makes that behaviour the default. We did not run either adapter, and the docs give no output to check, so the only signal is your own dev server and staging deploy working as before. If they fail after the swap, revert this commit; nothing else in the runbook depends on it.

Step 9. Install v8 and delete the future block

Your app now runs v8 behaviour on v7. Install v8 with the docs' command, and in the same commit delete the whole future block from react-router.config.ts:

# framework mode
npm install react-router@latest @react-router/{dev,node,etc.}@latest

# data/declarative mode
npm install react-router@latest
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
} satisfies Config;

The upgrade guide never tells you to remove the flags, and v8 refuses to load a config that still has any of the five keys. Its check looks for the key itself, so even false fails. With the block left in, npm run typecheck and the build both stopped at:

Error: The `future.v8_splitRouteModules` flag has been moved to a top-level `config.splitRouteModules` field (default `true`)

Removing only that line moved the failure to the next key (The `future.v8_viteEnvironmentApi` flag has been removed because Vite Environment API usage is now always enabled), so remove the block in one go. Once both changes are in, you should see:

$ npm ls react-router @react-router/dev @react-router/express @react-router/node --depth=0
├── @react-router/[email protected]
├── @react-router/[email protected]
├── @react-router/[email protected]
└── [email protected]

If the install fails with the Step 1 ERESOLVE (Conflicting peer dependency: [email protected]), use the same one-time --legacy-peer-deps install. We needed it on both the custom-server app and the default template.

v8 also ships ESM-only, and @react-router/serve moves to Express 5; our default-template app resolved [email protected] and served pages without changes. On a custom server, npm run dev returns a 500 from this point until you finish Step 10.

If you pre-render, read two open issues before you deploy. One reports that a Vite base plus ssr: false build that worked on 7.18.1 fails in v8 with SPA Mode: Received a 302 status code from `entry.server.tsx` (#15350). The other reports that ssr: false builds leave build/server/index.js behind (#15305). A third failure, Prerender: Request failed for /_.data: connect ECONNREFUSED, hit constrained CI containers now and then and was fixed in 8.5.0 (#15255).

Step 10. Add --conditions development to a custom server's dev script

React Router 8.5.1 publishes separate development and production builds behind package export conditions. react-router dev handles this itself: on 8.5.1 it printed [restart] Relaunching with --conditions=development and served our default-template app with no script change. A custom server started with plain node gets no such relaunch and can end up with two copies of react-router in one process. Update the dev script as the adapter docs show:

{
  "scripts": {
    "dev": "cross-env NODE_ENV=development node --conditions development server.js",
    "start": "node server.js"
  }
}

You should see the dev server render pages. With the old cross-env NODE_ENV=development node server.js script, our v8 dev server returned a 500 even though getLoadContext already returned a RouterContextProvider:

Error: Invalid `context` value provided to `handleRequest`. You must return an instance of `RouterContextProvider` from your `getLoadContext` function.

That matches GitHub issue #15146, where maintainer Jacob Ebey gave the same fix. The same split caused useNavigate() may be used only in the context of a <Router> component. from SSR-externalized dependencies in dev (#15232); upgrade to 8.3.0 or later for the fix on the react-router dev side. Production (npm start) worked without the flag in our test.

Verify

Run the full set against a production build and the dev server:

npm run typecheck
npm run build
grep -n "v8_" react-router.config.ts
npm ls react-router-dom
PORT=3000 npm start &
curl -s http://localhost:3000/ | grep -o '<title>[^<]*</title>'
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/_.data

Success means typecheck and build exit 0, the grep finds nothing, npm ls react-router-dom prints └── (empty) (and exits 1, which is fine here), your page title renders with real loader data (not undefined), and /_.data returns 200. Then start npm run dev, load a page that reads server context, and click a link so the client makes a data request. In your CDN logs, data requests should now end in _.data with no spike in 404s.

Rollback

Steps 2 to 8 are flags and code changes on v7, so each one rolls back with git revert of its commit, except Step 5 once it has shipped (see the first point below). To roll back Step 9, restore the package files and config from your last v7 commit, the one with all five flags on, and reinstall from that lockfile. Keep the v8-ready code:

git checkout <last-v7-commit> -- package.json package-lock.json react-router.config.ts
npm ci
npm run typecheck && npm run build

We ran exactly this: 7.18.5 with the flags on built, type-checked and served the v8-ready code in production and dev, and /_.data still answered 200.

  • Point of no return: none in your code, but Step 5 changes public URLs. A v7 build with v8_trailingSlashAwareDataRequests turned off returned 404 for /_.data in our test, so a rollback that also drops that flag breaks every open tab running the newer client bundle. Roll back to the all-flags commit, never further in one go.
  • Rollback does not undo CDN or rewrite rules you changed for _.data, or Express 5 if you upgraded it outside @react-router/serve.
  • No data is at risk: React Router stores nothing, so the only damage a bad deploy can do is failed requests until you revert.

What's next

The Future Changes page already tracks v9, estimated for mid-2027 when Node 22 reaches end of life, and it will require Node 24, so plan that runtime bump during v8. With React 19.2.7 now your floor, check the React Compiler adoption tracker before turning the compiler on in this app.