Upgrade i18next v26 + react-i18next v17 with SSR-safe init

Split initI18n() into separate server/client initialization paths
inspired by remix-i18next's architecture:

- initI18nServer(lng): no LanguageDetector, per-request language from
  Accept-Language header
- initI18nClient(): LanguageDetector reads <html lang> first to match
  server, then localStorage and navigator
- detectLanguage(request): parses Accept-Language for best match

Fixes rehydration errors caused by v26 removing initImmediate option
(replaced with initAsync) and LanguageDetector running on the server
where browser APIs don't exist.

Also makes <html lang> dynamic instead of hardcoded "en".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-29 11:38:36 +02:00
parent 4be716687a
commit 79f000fcc5
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
10 changed files with 166 additions and 40 deletions

View file

@ -4,6 +4,7 @@ import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client"; import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom"; import { HydratedRouter } from "react-router/dom";
import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
import { initI18nClient } from "@trails-cool/i18n";
const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ??
(import.meta.env.PROD ? "production" : "development"); (import.meta.env.PROD ? "production" : "development");
@ -24,6 +25,8 @@ Sentry.init({
enabled: import.meta.env.PROD && sentryEnvironment !== "ci", enabled: import.meta.env.PROD && sentryEnvironment !== "ci",
}); });
initI18nClient();
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(
document, document,

View file

@ -6,6 +6,7 @@ import { ServerRouter } from "react-router";
import { isbot } from "isbot"; import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server"; import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server";
import { initI18nServer, detectLanguage } from "@trails-cool/i18n";
const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development");
@ -32,6 +33,8 @@ export default function handleRequest(
routerContext: EntryContext, routerContext: EntryContext,
_loadContext: AppLoadContext, _loadContext: AppLoadContext,
) { ) {
initI18nServer(detectLanguage(request));
if (request.method.toUpperCase() === "HEAD") { if (request.method.toUpperCase() === "HEAD") {
return new Response(null, { return new Response(null, {
status: responseStatusCode, status: responseStatusCode,

View file

@ -4,17 +4,15 @@ import type { LinksFunction } from "react-router";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { initI18n } from "@trails-cool/i18n";
import { getSessionUser } from "~/lib/auth.server"; import { getSessionUser } from "~/lib/auth.server";
import stylesheet from "@trails-cool/ui/styles.css?url"; import stylesheet from "@trails-cool/ui/styles.css?url";
initI18n();
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation();
return ( return (
<html lang="en"> <html lang={i18n.language}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -4,6 +4,7 @@ import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client"; import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom"; import { HydratedRouter } from "react-router/dom";
import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
import { initI18nClient } from "@trails-cool/i18n";
const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ??
(import.meta.env.PROD ? "production" : "development"); (import.meta.env.PROD ? "production" : "development");
@ -24,6 +25,8 @@ Sentry.init({
enabled: import.meta.env.PROD && sentryEnvironment !== "ci", enabled: import.meta.env.PROD && sentryEnvironment !== "ci",
}); });
initI18nClient();
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(
document, document,

View file

@ -0,0 +1,79 @@
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import { initI18nServer, detectLanguage } from "@trails-cool/i18n";
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
initI18nServer(detectLanguage(request));
if (request.method.toUpperCase() === "HEAD") {
return new Response(null, {
status: responseStatusCode,
headers: responseHeaders,
});
}
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get("user-agent");
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
let timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(
() => abort(),
streamTimeout + 1000,
);
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough({
final(callback) {
clearTimeout(timeoutId);
timeoutId = undefined;
callback();
},
});
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
pipe(body);
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
}
},
},
);
});
}

View file

@ -3,16 +3,14 @@ import type { LinksFunction } from "react-router";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { initI18n } from "@trails-cool/i18n";
import stylesheet from "@trails-cool/ui/styles.css?url"; import stylesheet from "@trails-cool/ui/styles.css?url";
initI18n();
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation();
return ( return (
<html lang="en"> <html lang={i18n.language}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -49,7 +49,7 @@
"drizzle-postgis": "^1.1.1", "drizzle-postgis": "^1.1.1",
"eslint": "^10.1.0", "eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"i18next": "^25.10.4", "i18next": "^26.0.1",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.0.1", "jsdom": "^29.0.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@ -59,7 +59,7 @@
"prettier": "^3.8.1", "prettier": "^3.8.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^16.6.1", "react-i18next": "^17.0.1",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-router": "^7.13.2", "react-router": "^7.13.2",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",

View file

@ -10,7 +10,7 @@
"types": "./src/index.ts", "types": "./src/index.ts",
"peerDependencies": { "peerDependencies": {
"react": ">=18", "react": ">=18",
"i18next": ">=23", "i18next": ">=26",
"react-i18next": ">=14" "react-i18next": ">=17"
} }
} }

View file

@ -6,26 +6,68 @@ import de from "./locales/de.ts";
export const defaultNS = "common"; export const defaultNS = "common";
export const supportedLngs = ["en", "de"] as const;
export type SupportedLng = (typeof supportedLngs)[number];
export const resources = { export const resources = {
en: { common: en.common, planner: en.planner, journal: en.journal }, en: { common: en.common, planner: en.planner, journal: en.journal },
de: { common: de.common, planner: de.planner, journal: de.journal }, de: { common: de.common, planner: de.planner, journal: de.journal },
} as const; } as const;
export function initI18n() { const commonOptions = {
return i18n resources,
defaultNS,
fallbackLng: "en" as const,
supportedLngs: [...supportedLngs],
interpolation: { escapeValue: false },
initAsync: false,
};
/**
* Initialize i18next for server-side rendering.
* No language detector language is determined from the request.
*/
export function initI18nServer(lng: SupportedLng = "en") {
if (i18n.isInitialized) {
i18n.changeLanguage(lng);
return;
}
i18n.use(initReactI18next).init({ ...commonOptions, lng });
}
/**
* Initialize i18next for the client.
* Uses LanguageDetector with htmlTag priority so hydration matches the server.
*/
export function initI18nClient() {
if (i18n.isInitialized) return;
i18n
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
resources, ...commonOptions,
defaultNS, detection: {
fallbackLng: "en", order: ["htmlTag", "localStorage", "navigator"],
supportedLngs: ["en", "de"], caches: ["localStorage"],
interpolation: {
escapeValue: false,
}, },
showSupportNotice: false,
initImmediate: false,
}); });
} }
/**
* Detect the best supported language from a request's Accept-Language header.
*/
export function detectLanguage(request: Request): SupportedLng {
const header = request.headers.get("Accept-Language") ?? "";
for (const part of header.split(",")) {
const lang = part.split(";")[0]?.trim().toLowerCase();
if (!lang) continue;
for (const supported of supportedLngs) {
if (lang === supported || lang.startsWith(supported + "-")) {
return supported;
}
}
}
return "en";
}
export { i18n }; export { i18n };

36
pnpm-lock.yaml generated
View file

@ -113,8 +113,8 @@ importers:
specifier: ^10.1.8 specifier: ^10.1.8
version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) version: 10.1.8(eslint@10.1.0(jiti@2.6.1))
i18next: i18next:
specifier: ^25.10.4 specifier: ^26.0.1
version: 25.10.4(typescript@5.9.3) version: 26.0.1(typescript@5.9.3)
i18next-browser-languagedetector: i18next-browser-languagedetector:
specifier: ^8.2.1 specifier: ^8.2.1
version: 8.2.1 version: 8.2.1
@ -143,8 +143,8 @@ importers:
specifier: ^19.2.4 specifier: ^19.2.4
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
react-i18next: react-i18next:
specifier: ^16.6.1 specifier: ^17.0.1
version: 16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react-leaflet: react-leaflet:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -382,14 +382,14 @@ importers:
packages/i18n: packages/i18n:
dependencies: dependencies:
i18next: i18next:
specifier: '>=23' specifier: '>=26'
version: 25.10.4(typescript@5.9.3) version: 26.0.1(typescript@5.9.3)
react: react:
specifier: '>=18' specifier: '>=18'
version: 19.2.4 version: 19.2.4
react-i18next: react-i18next:
specifier: '>=14' specifier: '>=17'
version: 16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
packages/map: packages/map:
dependencies: dependencies:
@ -2744,10 +2744,10 @@ packages:
i18next-browser-languagedetector@8.2.1: i18next-browser-languagedetector@8.2.1:
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
i18next@25.10.4: i18next@26.0.1:
resolution: {integrity: sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==} resolution: {integrity: sha512-vtz5sXU4+nkCm8yEU+JJ6yYIx0mkg9e68W0G0PXpnOsmzLajNsW5o28DJMqbajxfsfq0gV3XdrBudsDQnwxfsQ==}
peerDependencies: peerDependencies:
typescript: ^5 typescript: ^5 || ^6
peerDependenciesMeta: peerDependenciesMeta:
typescript: typescript:
optional: true optional: true
@ -3278,14 +3278,14 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.4 react: ^19.2.4
react-i18next@16.6.1: react-i18next@17.0.1:
resolution: {integrity: sha512-izjXh+AkBLy3h3xe3sh6Gg1flhFHc3UyzsMftMKYJr2Z7WvAZQIdjjpHypctN41zFoeLdJUNGDgP1+Qich2fYg==} resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==}
peerDependencies: peerDependencies:
i18next: '>= 25.6.2' i18next: '>= 26.0.1'
react: '>= 16.8.0' react: '>= 16.8.0'
react-dom: '*' react-dom: '*'
react-native: '*' react-native: '*'
typescript: ^5 typescript: ^5 || ^6
peerDependenciesMeta: peerDependenciesMeta:
react-dom: react-dom:
optional: true optional: true
@ -6169,7 +6169,7 @@ snapshots:
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
i18next@25.10.4(typescript@5.9.3): i18next@26.0.1(typescript@5.9.3):
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
optionalDependencies: optionalDependencies:
@ -6637,11 +6637,11 @@ snapshots:
react: 19.2.4 react: 19.2.4
scheduler: 0.27.0 scheduler: 0.27.0
react-i18next@16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): react-i18next@17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1 html-parse-stringify: 3.0.1
i18next: 25.10.4(typescript@5.9.3) i18next: 26.0.1(typescript@5.9.3)
react: 19.2.4 react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies: optionalDependencies: