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

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

View file

@ -6,26 +6,68 @@ import de from "./locales/de.ts";
export const defaultNS = "common";
export const supportedLngs = ["en", "de"] as const;
export type SupportedLng = (typeof supportedLngs)[number];
export const resources = {
en: { common: en.common, planner: en.planner, journal: en.journal },
de: { common: de.common, planner: de.planner, journal: de.journal },
} as const;
export function initI18n() {
return i18n
const commonOptions = {
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(initReactI18next)
.init({
resources,
defaultNS,
fallbackLng: "en",
supportedLngs: ["en", "de"],
interpolation: {
escapeValue: false,
...commonOptions,
detection: {
order: ["htmlTag", "localStorage", "navigator"],
caches: ["localStorage"],
},
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 };