Skip to content

chore(deps): upgrade React Router 7 → 8 (+ remix-i18next 8, Netlify plugin 4, Node 22.22) - #405

Merged
HerrBertling merged 3 commits into
mainfrom
chore/react-router-8-upgrade
Sep 6, 2026
Merged

HerrBertling merged 3 commits into
mainfrom
chore/react-router-8-upgrade

Conversation

@HerrBertling

Copy link
Copy Markdown
Owner

Upgrades React Router 7 → 8 as one coordinated change. Replaces dependabot's #394.

Why this instead of #394

Dependabot proposed the React Router family + remix-i18next bump in #394, but that PR cannot work, because a working RR8 upgrade needs three things dependabot is not allowed to touch:

  1. @netlify/vite-plugin-react-router 2.1.3 → 4.0.0. Not in chore(deps): bump react-router, @react-router/node, @react-router/serve, remix-i18next, @react-router/dev and @react-router/fs-routes #394's scope, and without it the deploy is silently broken (see below).
  2. The Node floor. RR8, remix-i18next 8 and the Netlify plugin v4 all require Node >=22.22.0. .nvmrc was v22.14.0.
  3. Source changes for four documented breaking changes.

#394 also targets react-router 8.3.0; this uses 8.3.1, the current 8.3 patch.

The blocker: the Netlify plugin silently stops emitting the server handler

RR8 makes Vite's Environment API mandatory (future.v8_viteEnvironmentApi was removed and the behaviour is now unconditional — react-router#15077). @netlify/vite-plugin-react-router@2.1.3 gates its work on isSsrBuild, which is never true under that model. It then produces no .netlify/v1/functions/react-router-server.mjs at all, while npm run build still exits 0. That is netlify/remix-compute#698, fixed in plugin v4.0.0.

Measured here, single-variable (same tree, same commit, only node_modules/@netlify/vite-plugin-react-router swapped, rm -rf build .netlify/v1/functions before each build):

npm run build .netlify/v1/functions/react-router-server.mjs build/server/
main — RR 7.15.0 + plugin 2.1.3 exit 0 288 B, present server.js, server-build.js
RR 8.3.1 + plugin 2.1.3 exit 0 never created — .netlify/v1/ is empty index.js
RR 8.3.1 + plugin 4.0.0 (this PR) exit 0 427 B, present index.js

So a green npm run build and a green CI verify are not sufficient evidence for this PR, and neither was checked by #394.

Note the artifact shape legitimately changes with plugin v4: it no longer injects a virtual netlify-server entry into the React Router build, so there is no build/server/server.js any more. Instead the generated function imports the standard RR server build directly:

import { createRequestHandler } from "@netlify/vite-plugin-react-router/serverless";
import * as build from "../../../build/server/index.js";
export default createRequestHandler({ build });
export const config = { name: "React Router server handler", generator: "@netlify/vite-plugin-react-router@4.0.0", path: "/*", excludedPath: ["/.netlify/*"], preferStatic: true };

I imported that emitted .mjs in Node and invoked its default export directly, to prove the handler is not just present but functional:

path status <html> <title>
/de 200 lang="de" dir="ltr" Willkommen | REDEZEIT FÜR DICH …
/de/ich-suche-redezeit 200 lang="de" Ich brauche Redezeit. | …
/en/blog 200 lang="en" Lesezeit – das Redezeit Blog. | …
/uk 200 lang="uk" Ласкаво просимо! | …
/ru 200 lang="ru" Добро пожаловать! | …

Those titles come out of the meta functions, so this also exercises the data → loaderData rename end to end.

Node bump

.nvmrc v22.14.0 → v22.23.2 (current Node 22 LTS "Jod"), engines.node >=22.12.0 → >=22.22.0. Required by all three of RR8, remix-i18next 8 and Netlify plugin v4. CI picks this up automatically via node-version-file: .nvmrc, and Netlify reads .nvmrc too.

Dependencies

package from to
react-router, @react-router/{node,serve,dev,fs-routes} 7.15.0 8.3.1
remix-i18next 7.4.2 8.0.0
@netlify/vite-plugin-react-router 2.1.3 4.0.0 (ESM-only)
react, react-dom 19.2.6 19.2.8

remix-i18next 8 belongs in this PR and not a separate one: it declares react-router: ^8 as a peer, so it cannot land before RR8 and RR8 cannot land while it is on 7.x.

React moves because RR8's peer floor is >=19.2.7. react and react-dom move together because react-dom declares react: ^19.2.8 — a skew there is a real renderer bug, not a cosmetic lockfile difference.

Installed with plain npm install. No --force, no --legacy-peer-deps. Lockfile churn is 13 added / 100 removed / 46 changed, and every changed entry is downstream of these bumps (notably express 4 → 5 and its middleware, pulled in by @react-router/serve 8). No unrelated direct dependency moved.

Source changes

Each maps to a documented RR8 / remix-i18next 8 breaking change:

  1. react-router.config.ts — future.v8_middleware removed in 8.0.0 (react-router#15078); middleware is always on. FutureConfig now only holds unstable_optimizeDeps / unstable_enableNodeReadableStream, so leaving the flag is a hard tsc error. The app was already running with the flag true, so there was no behavioural migration to do — just drop the block.
  2. app/middleware/i18next.ts — remix-i18next 8 collapsed its subpath exports to the package root (remix-i18next/middleware → remix-i18next) and now hands findLocale the full middleware args: findLocale(request) → findLocale({ request }).
  3. app/root.tsx — remix-i18next 8 deletes useChangeLanguage and the whole remix-i18next/react subpath, with no replacement export. Its own v7 @deprecated note prescribed i18n.changeLanguage(loaderData.locale), so the hook is reimplemented locally as exactly that, in a useEffect with the same i18n.language !== locale guard the removed hook used.
  4. 11 route modules — RR8 removed the deprecated data field on MetaArgs in favour of loaderData (react-router#14931): $locale._index, $locale.$slug, $locale.blog.$post, the four speaking-time routes (de.ich-suche-redezeit, {en,ru,uk}.i-need-speaking-time) and the four network-partner routes (de.netzwerk-partner-medien, {en,ru,uk}.network-partner-media).

Audited against the rest of the v8 changelog and found not applicable here: react-router-dom (already unused), hasErrorBoundary, the removed Cloudflare dev proxy, getLoadContext (none — the Netlify plugin owns the handler), and future.v8_splitRouteModules (never set; now a top-level option defaulting to true).

Verification

  • npm install — clean, no --force / --legacy-peer-deps
  • npm ci — exit 0
  • npm run typecheck (react-router typegen && tsc) — exit 0
  • npm run build — exit 0
  • Netlify artifact check — rm -rf build .netlify/v1/functions, rebuild, handler emitted and functionally verified (table above)
  • npx biome check app scripts types *.ts *.js *.json — 107 files, no fixes (identical file count to main)
  • npx vitest --run --dir app — 4 files / 73 tests passed
  • Browser smoke on the dev server: /de, /en, /ru, /uk, /de/ich-suche-redezeit, /uk/i-need-speaking-time, /en/blog all render fully translated content. Grepped the SSR HTML of all seven for leaked i18n keys (filter.*, languageTags.*, genderTags.*, …) — zero hits, which is the thing most likely to break across a remix-i18next major.
  • Coach filters — counts match main's baselines exactly: no filter 365, lang=de 365, lang=en 100, lang=uk 12, lang=ru 18. Also driven through the real client-side path: checking a language radio fires the <Form method="get"> submit, the URL becomes ?lang=ru&search= and the list goes 365 → 18 without a document load — so hydration, useSubmit and loader revalidation all work under RR8.
  • Console — only the noise this repo already documents as benign: the post-npm ci Vite dep-optimizer 504 (Outdated Optimize Dep) burst (count frozen at 18 across further navigations; a fresh network capture is all 200s, zero 504s) and the pre-existing data-headlessui-focus-visible hydration attribute mismatch. No server-side errors.
  • The vite-tsconfig-paths deprecation notice in the build output is pre-existing on main (Vite 8), unchanged here.

Conflicts with #404

#404 (the i18n stack upgrade: remix-i18next 7.5.0, i18next 26, react-i18next 17) and this PR both move remix-i18next — to 7.5.0 there, 8.0.0 here — and 8.0.0 requires react-router ^8, so they cannot both apply. Whichever merges second needs a rebase:

Refs #394 (supersedes — please close it when this lands), #389.

🤖 Generated with Claude Code

@HerrBertling

Copy link
Copy Markdown
Owner Author

Deploy-preview render check

CI is green (verify pass, deploy-preview pass, Header rules / Redirect rules pass, GitGuardian pass). Green checks are not the point though — here is the preview at https://deploy-preview-405--keen-liskov-77de74.netlify.app actually serving SSR'd content, which is what the plugin v4 bump exists to make possible:

path status <html> <title> <article> count leaked i18n keys
/de 200 lang="de" dir="ltr" Willkommen | … – none
/en 200 lang="en" Welcome | … – none
/ru 200 lang="ru" Добро пожаловать! | … – none
/uk 200 lang="uk" Ласкаво просимо! | … – none
/de/ich-suche-redezeit 200 lang="de" Ich brauche Redezeit. | … 365 none
/de/ich-suche-redezeit?lang=uk 200 lang="de" Ich brauche Redezeit. | … 12 none
/en/blog 200 lang="en" Lesezeit – das Redezeit Blog. | … – none

The ?lang=uk row is the one that proves the Netlify function is really running: 365 → 12 is loader-side filtering, not a static file. Counts match main's baselines.

Loading /de and /uk from the preview in a browser gives fully translated body copy and zero console errors on the production build.

HerrBertling and others added 3 commits September 6, 2026 16:44
…Node 22.22

Coordinated upgrade — these versions only resolve together:

- react-router / @react-router/{node,serve,dev,fs-routes} 7.15.0 -> 8.3.1
- remix-i18next 7.4.2 -> 8.0.0 (peer: react-router ^8)
- @netlify/vite-plugin-react-router 2.1.3 -> 4.0.0 (ESM-only)
- react / react-dom 19.2.6 -> 19.2.8 (RR8 peer floor is >=19.2.7; the two
  move in lockstep because react-dom pins react with a caret on the exact
  version)

@netlify/vite-plugin-react-router 2.1.3 is not optional to leave behind: RR8
makes Vite's Environment API mandatory, and 2.1.3 gates its work on
`isSsrBuild`, which is never true under that model. It then silently stops
emitting .netlify/v1/functions/react-router-server.mjs while `npm run build`
still exits 0 — netlify/remix-compute#698, fixed in v4.0.0.

Node floor: RR8, remix-i18next 8 and the Netlify plugin all require
>=22.22.0. .nvmrc goes to v22.23.2 (current 22 LTS "Jod"); engines.node to
>=22.22.0. CI reads .nvmrc via node-version-file, and so does Netlify.

Installed with plain `npm install` — no --force, no --legacy-peer-deps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four documented breaking changes, one commit each in spirit but landed
together because none of them typechecks without the others:

1. react-router.config.ts — `future.v8_middleware` was removed in RR 8.0.0
   (react-router#15078); middleware is unconditionally on. `FutureConfig`
   now only carries `unstable_optimizeDeps` / `unstable_enableNodeReadableStream`,
   so leaving the flag is a hard tsc error. The app was already running with
   the flag set to true, so no behavioural migration was needed.

2. app/middleware/i18next.ts — remix-i18next 8 collapsed its subpath exports
   to the package root, so `remix-i18next/middleware` becomes `remix-i18next`,
   and the custom locale finder now receives the full middleware args:
   `findLocale(request)` -> `findLocale({ request })`.

3. app/root.tsx — remix-i18next 8 deletes `useChangeLanguage` (and the whole
   `remix-i18next/react` subpath) with no replacement export. Its own v7
   @deprecated note prescribed `i18n.changeLanguage(loaderData.locale)`, so
   the hook is reimplemented locally as exactly that, in an effect with the
   same `i18n.language !== locale` guard the removed hook used.

4. 11 route modules — RR8 removed the deprecated `data` field on `MetaArgs`
   in favour of `loaderData` (react-router#14931). Renamed in every `meta`
   that used it: $locale._index, $locale.$slug, $locale.blog.$post, the four
   speaking-time routes (de.ich-suche-redezeit, {en,ru,uk}.i-need-speaking-time)
   and the four network-partner routes ({de.netzwerk-partner-medien,
   {en,ru,uk}.network-partner-media).

Also audited and found not applicable: `react-router-dom` (already unused),
`hasErrorBoundary`, the Cloudflare dev proxy, `getLoadContext`, and
`future.v8_splitRouteModules` (never set; now a top-level option defaulting
to true).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
React Router 8 passes middleware the raw single-fetch request URL. A
client-side navigation to a locale root therefore arrives as "/uk.data",
so `pathname.split("/").at(1)` returned "uk.data" — not in
supportedLanguages — and remix-i18next fell through to Accept-Language,
resolving the root loader's locale to "en".

Observed before this fix: clicking the language switcher to Ukrainian
rendered <html lang="en"> and an English cookie banner on the Ukrainian
homepage. React Router 7 handed middleware the already-stripped
pathname, so this only regressed with the v8 upgrade.

Nested paths were unaffected ("/de/ich-suche-redezeit.data" still yields
"de"), which is why direct page loads and deeper routes looked fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HerrBertling
HerrBertling force-pushed the chore/react-router-8-upgrade branch from 974c6c7 to 50c776f Compare September 6, 2026 15:03
@HerrBertling

Copy link
Copy Markdown
Owner Author

Rebased onto main (post-#404) — and found a React Router 8 regression

Rebase

Conflict was package.json + package-lock.json on remix-i18next only (main had 7.5.0 from #404, this branch needs 8.0.0). Kept 8.0.0 and re-applied main's i18n stack on top.

Peers are fully coherent — no --force, no --legacy-peer-deps:

  • remix-i18next@8.0.0 peers i18next ^24 || ^25 || ^26 and react-router ^8.0.0 — main's 26.4.2 satisfies it
  • react-i18next@17.0.13 peers i18next >= 26.2.0 — satisfied
  • remix-i18next 8 does not peer on react-i18next, so there was no three-way constraint

app/entry.client.tsx did not conflict, so #404's all-locale-bundles fix carried over intact.

The regression (50c776f)

React Router 8 hands middleware the raw single-fetch URL; RR7 handed it the stripped pathname. So in app/middleware/i18next.ts:

const pathname = new URL(request.url).pathname;
return pathname.split("/").at(1) || null;

a client-side navigation to a locale root arrives as /uk.data, segment 1 is "uk.data", that is not in supportedLanguages, and remix-i18next falls through to Accept-Language — so the root loader returns locale: "en".

Instrumented on the dev server:

url=…/uk.data?_routes=root                    pathname=/uk.data                seg=uk.data
url=…/de/ich-suche-redezeit.data?_routes=root pathname=/de/…redezeit.data      seg=de

A/B against main (RR7, same fetch): /uk.data → "uk". On this branch: "en". Introduced by the v8 upgrade.

Symptom: switch to Ukrainian → /uk renders Ukrainian CMS content but <html lang="en"> and an English cookie banner. Persistent, not a render lag. Nested paths were unaffected (/de/ich-suche-redezeit.data still yields de), which is exactly why direct loads and deeper routes all looked healthy.

The hand-rolled useChangeLanguage in app/root.tsx is not at fault — it faithfully calls i18n.changeLanguage(loaderData.locale). It was being handed the wrong locale.

Fix is one line: strip the .data suffix before reading the locale segment.

Verification (with the fix)

npm ci · typecheck · scoped biome (107 files, 0 findings) · scoped vitest (4 files / 73 tests) — all green. Clean rebuild after rm -rf build .netlify/v1/functions emits .netlify/v1/functions/react-router-server.mjs, generator @netlify/vite-plugin-react-router@4.0.0, importing build/server/index.js. There is deliberately no build/server/server.js under plugin v4.

Locale-switch matrix through the real switcher, <html lang> correct and zero German leakage throughout:

Path Result
de → en Popular topics / More filters / Languages / listeners found
de → ru Популярные темы / Дополнительные фильтры
de → uk Популярні теми / Додаткові фільтри
en → de German restored

Worth flagging: before the fix, the filter-page assertions also passed. The German-leak checks alone would have let this ship. It only surfaced because <html lang> and the cookie banner were checked on the locale-root landing page — the one place the bug shows.

🤖 Generated with Claude Code

@HerrBertling
HerrBertling merged commit 635e5d6 into main Sep 6, 2026
6 checks passed
@HerrBertling
HerrBertling deleted the chore/react-router-8-upgrade branch September 6, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant