diff --git a/apps/journal/app/components/ClientDate.tsx b/apps/journal/app/components/ClientDate.tsx
index e2fcf06..af210ae 100644
--- a/apps/journal/app/components/ClientDate.tsx
+++ b/apps/journal/app/components/ClientDate.tsx
@@ -1,15 +1,12 @@
-import { useState, useEffect } from "react";
+import { useLocale } from "./LocaleContext";
/**
- * Renders a date only on the client to avoid SSR hydration mismatches.
- * Server renders empty, client fills in with user's locale.
+ * Renders a date formatted with the server-detected locale,
+ * ensuring SSR and client output match (no hydration flicker).
*/
export function ClientDate({ iso }: { iso: string }) {
- const [formatted, setFormatted] = useState(new Date(iso).toLocaleDateString("en-US"));
-
- useEffect(() => {
- setFormatted(new Date(iso).toLocaleDateString());
- }, [iso]);
-
- return ;
+ const locale = useLocale();
+ return (
+
+ );
}
diff --git a/apps/journal/app/components/LocaleContext.tsx b/apps/journal/app/components/LocaleContext.tsx
new file mode 100644
index 0000000..15902c8
--- /dev/null
+++ b/apps/journal/app/components/LocaleContext.tsx
@@ -0,0 +1,17 @@
+import { createContext, useContext } from "react";
+
+const LocaleContext = createContext("en");
+
+export function LocaleProvider({
+ locale,
+ children,
+}: {
+ locale: string;
+ children: React.ReactNode;
+}) {
+ return {children};
+}
+
+export function useLocale() {
+ return useContext(LocaleContext);
+}
diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx
index f77e5e9..8e3f019 100644
--- a/apps/journal/app/entry.client.tsx
+++ b/apps/journal/app/entry.client.tsx
@@ -4,6 +4,7 @@ import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
+import { initI18nClient } from "@trails-cool/i18n";
const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ??
(import.meta.env.PROD ? "production" : "development");
@@ -24,6 +25,8 @@ Sentry.init({
enabled: import.meta.env.PROD && sentryEnvironment !== "ci",
});
+initI18nClient();
+
startTransition(() => {
hydrateRoot(
document,
diff --git a/apps/journal/app/entry.server.tsx b/apps/journal/app/entry.server.tsx
index 01dbf3d..0bdfe8f 100644
--- a/apps/journal/app/entry.server.tsx
+++ b/apps/journal/app/entry.server.tsx
@@ -6,6 +6,7 @@ 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";
const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development");
@@ -32,6 +33,8 @@ export default function handleRequest(
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
+ initI18nServer(detectLanguage(request));
+
if (request.method.toUpperCase() === "HEAD") {
return new Response(null, {
status: responseStatusCode,
diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx
index 159af7c..be4a5f4 100644
--- a/apps/journal/app/root.tsx
+++ b/apps/journal/app/root.tsx
@@ -4,17 +4,17 @@ import type { LinksFunction } from "react-router";
import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react";
import { useTranslation } from "react-i18next";
-import { initI18n } from "@trails-cool/i18n";
+import { detectLocale } from "@trails-cool/i18n";
import { getSessionUser } from "~/lib/auth.server";
+import { LocaleProvider } from "~/components/LocaleContext";
import stylesheet from "@trails-cool/ui/styles.css?url";
-initI18n();
-
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export function Layout({ children }: { children: React.ReactNode }) {
+ const { i18n } = useTranslation();
return (
-
+
@@ -35,7 +35,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
- return { user: user ? { id: user.id, username: user.username } : null };
+ const locale = detectLocale(request);
+ return { user: user ? { id: user.id, username: user.username } : null, locale };
}
function NavBar({ user }: { user: { id: string; username: string } | null }) {
@@ -112,6 +113,7 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) {
export default function App({ loaderData }: Route.ComponentProps) {
const user = loaderData?.user;
+ const locale = loaderData?.locale ?? "en";
useEffect(() => {
if (user) {
Sentry.setUser({ id: user.id, username: user.username });
@@ -121,10 +123,10 @@ export default function App({ loaderData }: Route.ComponentProps) {
}, [user]);
return (
- <>
+
- >
+
);
}
diff --git a/apps/journal/app/routes/settings.tsx b/apps/journal/app/routes/settings.tsx
index fda39b5..11b75b7 100644
--- a/apps/journal/app/routes/settings.tsx
+++ b/apps/journal/app/routes/settings.tsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { data, redirect, useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
+import { ClientDate } from "~/components/ClientDate";
import { eq } from "drizzle-orm";
import type { Route } from "./+types/settings";
import { getSessionUser } from "~/lib/auth.server";
@@ -217,9 +218,8 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
{transportLabel(passkey.transports, t)}
- {t("settings.security.addedOn", {
- date: new Date(passkey.createdAt).toLocaleDateString(),
- })}
+ {t("settings.security.addedOn", { date: "" })}
+
{
hydrateRoot(
document,
diff --git a/apps/planner/app/entry.server.tsx b/apps/planner/app/entry.server.tsx
new file mode 100644
index 0000000..9220df5
--- /dev/null
+++ b/apps/planner/app/entry.server.tsx
@@ -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 | undefined = setTimeout(
+ () => abort(),
+ streamTimeout + 1000,
+ );
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ [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);
+ }
+ },
+ },
+ );
+ });
+}
diff --git a/apps/planner/app/root.tsx b/apps/planner/app/root.tsx
index 29653b5..cb6df2b 100644
--- a/apps/planner/app/root.tsx
+++ b/apps/planner/app/root.tsx
@@ -3,16 +3,14 @@ import type { LinksFunction } from "react-router";
import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react";
import { useTranslation } from "react-i18next";
-import { initI18n } from "@trails-cool/i18n";
import stylesheet from "@trails-cool/ui/styles.css?url";
-initI18n();
-
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export function Layout({ children }: { children: React.ReactNode }) {
+ const { i18n } = useTranslation();
return (
-
+
diff --git a/package.json b/package.json
index 66b09f7..3434381 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
"drizzle-postgis": "^1.1.1",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
- "i18next": "^25.10.4",
+ "i18next": "^26.0.1",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.0.1",
"leaflet": "^1.9.4",
@@ -59,7 +59,7 @@
"prettier": "^3.8.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
- "react-i18next": "^16.6.1",
+ "react-i18next": "^17.0.1",
"react-leaflet": "^5.0.0",
"react-router": "^7.13.2",
"tailwindcss": "^4.2.2",
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index f55bf10..f17b2dc 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -10,7 +10,7 @@
"types": "./src/index.ts",
"peerDependencies": {
"react": ">=18",
- "i18next": ">=23",
- "react-i18next": ">=14"
+ "i18next": ">=26",
+ "react-i18next": ">=17"
}
}
diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts
index 8f3e652..d37030d 100644
--- a/packages/i18n/src/index.ts
+++ b/packages/i18n/src/index.ts
@@ -6,26 +6,87 @@ 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";
+}
+
+/**
+ * Extract the full locale (e.g. "de-DE") from a request's Accept-Language header.
+ * Falls back to the supported language (e.g. "en") if no region tag is present.
+ */
+export function detectLocale(request: Request): string {
+ const header = request.headers.get("Accept-Language") ?? "";
+ for (const part of header.split(",")) {
+ const tag = part.split(";")[0]?.trim();
+ if (!tag) continue;
+ const lang = tag.toLowerCase();
+ for (const supported of supportedLngs) {
+ if (lang === supported || lang.startsWith(supported + "-")) {
+ return tag; // preserve original casing (e.g. "de-DE")
+ }
+ }
+ }
+ return "en";
+}
+
export { i18n };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1f91dbd..8c7afc4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -113,8 +113,8 @@ importers:
specifier: ^10.1.8
version: 10.1.8(eslint@10.1.0(jiti@2.6.1))
i18next:
- specifier: ^25.10.4
- version: 25.10.4(typescript@5.9.3)
+ specifier: ^26.0.1
+ version: 26.0.1(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: ^8.2.1
version: 8.2.1
@@ -143,8 +143,8 @@ importers:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
react-i18next:
- specifier: ^16.6.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)
+ specifier: ^17.0.1
+ 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:
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)
@@ -382,14 +382,14 @@ importers:
packages/i18n:
dependencies:
i18next:
- specifier: '>=23'
- version: 25.10.4(typescript@5.9.3)
+ specifier: '>=26'
+ version: 26.0.1(typescript@5.9.3)
react:
specifier: '>=18'
version: 19.2.4
react-i18next:
- specifier: '>=14'
- 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)
+ specifier: '>=17'
+ 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:
dependencies:
@@ -2744,10 +2744,10 @@ packages:
i18next-browser-languagedetector@8.2.1:
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
- i18next@25.10.4:
- resolution: {integrity: sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==}
+ i18next@26.0.1:
+ resolution: {integrity: sha512-vtz5sXU4+nkCm8yEU+JJ6yYIx0mkg9e68W0G0PXpnOsmzLajNsW5o28DJMqbajxfsfq0gV3XdrBudsDQnwxfsQ==}
peerDependencies:
- typescript: ^5
+ typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
@@ -3278,14 +3278,14 @@ packages:
peerDependencies:
react: ^19.2.4
- react-i18next@16.6.1:
- resolution: {integrity: sha512-izjXh+AkBLy3h3xe3sh6Gg1flhFHc3UyzsMftMKYJr2Z7WvAZQIdjjpHypctN41zFoeLdJUNGDgP1+Qich2fYg==}
+ react-i18next@17.0.1:
+ resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==}
peerDependencies:
- i18next: '>= 25.6.2'
+ i18next: '>= 26.0.1'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
- typescript: ^5
+ typescript: ^5 || ^6
peerDependenciesMeta:
react-dom:
optional: true
@@ -6169,7 +6169,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.29.2
- i18next@25.10.4(typescript@5.9.3):
+ i18next@26.0.1(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
optionalDependencies:
@@ -6637,11 +6637,11 @@ snapshots:
react: 19.2.4
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:
'@babel/runtime': 7.29.2
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
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies: