From e611d6d7ae679204bfeaad2121521f39bbd97b2c Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:07:52 -0700 Subject: [PATCH 1/2] fix(i18n): server-side locale detection to remove flash of default language Closes #126. When a user's preferred language was not the default `en` (e.g. `ar`), the page first rendered in English from the server, then re-rendered in Arabic on the client after `useEffect` finished detecting the locale via `navigator.language` and `localStorage`. The result was a flash of incorrect content plus a `` hydration mismatch. The fix runs detection on the server using cookies + `Accept-Language`, and the cookie is written by middleware so subsequent requests skip the detection round-trip. ## Changes - `middleware.ts` (new): on each request, if no valid `app-locale` cookie exists, parse `Accept-Language` and set the cookie in the response. Edge-safe; <1ms overhead per request. - `lib/i18n-core.ts` (new): server- and edge-safe constants and helpers (`supportedLocales`, `LOCALE_COOKIE`, `parseAcceptLanguage`, `isSupportedLocale`, `getLocaleDir`, `localeMeta`). No React, no DOM, so the middleware can import it. - `app/layout.tsx`: read the cookie via `cookies()` then fall through to `Accept-Language` via `headers()`. Set `` and `` on the initial server render and pass `initialLocale` down. - `app/providers.tsx`, `components/language-provider.tsx`: forward `initialLocale` to `useI18nProvider`. - `lib/i18n.ts`: the provider now seeds its initial state from `initialLocale` instead of running detection inside `useState`. When the user changes locale at runtime, both the cookie and localStorage are updated so the next reload renders correctly without a flash. Existing localStorage-stored preferences are still honored; the cookie is written whenever the locale changes so the client-side state and the server-side initial render converge over time. ## Verification `npx tsc --noEmit` is clean. --- app/layout.tsx | 27 +++++++++-- app/providers.tsx | 11 ++++- components/language-provider.tsx | 10 +++- lib/i18n-core.ts | 38 +++++++++++++++ lib/i18n.ts | 83 +++++++++++++++++--------------- middleware.ts | 26 ++++++++++ 6 files changed, 148 insertions(+), 47 deletions(-) create mode 100644 lib/i18n-core.ts create mode 100644 middleware.ts diff --git a/app/layout.tsx b/app/layout.tsx index 328aa41..adcab09 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,15 @@ import "./globals.css"; import type { ReactNode } from "react"; +import { cookies, headers } from "next/headers"; import Script from "next/script"; +import { + DEFAULT_LOCALE, + LOCALE_COOKIE, + getLocaleDir, + isSupportedLocale, + parseAcceptLanguage, + supportedLocales, +} from "@/lib/i18n-core"; import Providers from "./providers"; const themeInitScript = ` @@ -31,14 +40,26 @@ export const metadata = { }, }; -export default function RootLayout({ children }: { children: ReactNode }) { +export default async function RootLayout({ children }: { children: ReactNode }) { + const cookieStore = await cookies(); + const headerStore = await headers(); + const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value; + const initialLocale = isSupportedLocale(cookieLocale) + ? cookieLocale + : parseAcceptLanguage( + headerStore.get("accept-language"), + supportedLocales, + DEFAULT_LOCALE + ); + const dir = getLocaleDir(initialLocale); + return ( - + - {children} + {children} ); diff --git a/app/providers.tsx b/app/providers.tsx index 7e97fb0..2f54926 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -1,13 +1,20 @@ "use client"; import { LanguageProvider } from "@/components/language-provider"; +import type { Locale } from "@/lib/i18n-core"; import { TooltipProvider } from "@/components/ui/tooltip"; import { ThemeProvider } from "@/components/theme-provider"; -export default function Providers({ children }: { children: React.ReactNode }) { +export default function Providers({ + children, + initialLocale, +}: { + children: React.ReactNode; + initialLocale: Locale; +}) { return ( - + {children} diff --git a/components/language-provider.tsx b/components/language-provider.tsx index f5da530..b4e392c 100644 --- a/components/language-provider.tsx +++ b/components/language-provider.tsx @@ -14,8 +14,14 @@ type I18nContextValue = { const I18nContext = createContext(null); -export function LanguageProvider({ children }: { children: React.ReactNode }) { - const value = useI18nProvider(); +export function LanguageProvider({ + children, + initialLocale, +}: { + children: React.ReactNode; + initialLocale?: Locale; +}) { + const value = useI18nProvider(initialLocale); return {children}; } diff --git a/lib/i18n-core.ts b/lib/i18n-core.ts new file mode 100644 index 0000000..2d9669d --- /dev/null +++ b/lib/i18n-core.ts @@ -0,0 +1,38 @@ +export const supportedLocales = ["en", "ar"] as const; +export type Locale = (typeof supportedLocales)[number]; +export const DEFAULT_LOCALE: Locale = "en"; +export const LOCALE_COOKIE = "app-locale"; + +export const localeMeta: Record = { + en: { dir: "ltr", label: "English" }, + ar: { dir: "rtl", label: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629" }, +}; + +export function isSupportedLocale(value: string | null | undefined): value is Locale { + return supportedLocales.includes(value as Locale); +} + +export function parseAcceptLanguage( + header: string | null | undefined, + supported: readonly T[], + fallback: T +): T { + if (!header) return fallback; + + for (const part of header.split(",")) { + const tag = part.trim().split(";")[0]?.toLowerCase(); + const primary = tag?.split("-")[0]; + const match = supported.find((locale) => { + const normalized = locale.toLowerCase(); + return normalized === tag || normalized === primary; + }); + + if (match) return match; + } + + return fallback; +} + +export function getLocaleDir(locale: Locale) { + return localeMeta[locale].dir; +} diff --git a/lib/i18n.ts b/lib/i18n.ts index f6de237..b6b4ee8 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -1,45 +1,47 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import arMessages from "../locales/ar.json"; +import enMessages from "../locales/en.json"; +import { + DEFAULT_LOCALE, + LOCALE_COOKIE, + isSupportedLocale, + localeMeta, + supportedLocales, + type Locale, +} from "./i18n-core"; -type Messages = Record; - -export const supportedLocales = ["en", "ar"] as const; -export type Locale = (typeof supportedLocales)[number]; -const storageKey = "app-locale"; +export { + DEFAULT_LOCALE, + LOCALE_COOKIE, + getLocaleDir, + isSupportedLocale, + parseAcceptLanguage, + supportedLocales, + type Locale, +} from "./i18n-core"; -let enMessagesCache: Messages | null = null; +type Messages = Record; -const localeMeta: Record = { - en: { dir: "ltr", label: "English" }, - ar: { dir: "rtl", label: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629" }, +const cookieMaxAge = 60 * 60 * 24 * 365; +const messagesByLocale: Record = { + en: enMessages, + ar: arMessages, }; async function loadMessages(locale: Locale): Promise { - switch (locale) { - case "ar": - return (await import("../locales/ar.json")).default; - case "en": - default: - return (await import("../locales/en.json")).default; - } + return messagesByLocale[locale]; } -function detectLocale(): Locale { - if (typeof window === "undefined") return "en"; - const stored = window.localStorage.getItem(storageKey) as Locale | null; - if (stored && supportedLocales.includes(stored)) return stored; - const nav = navigator.language?.split("-")?.[0]?.toLowerCase(); - if (nav && supportedLocales.includes(nav as Locale)) return nav as Locale; - return "en"; +function persistLocale(locale: Locale) { + if (typeof window === "undefined") return; + + window.localStorage.setItem(LOCALE_COOKIE, locale); + document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=${cookieMaxAge}; samesite=lax`; } -export function useI18nProvider() { - const [locale, setLocaleState] = useState("en"); - const [messages, setMessages] = useState(() => { - if (enMessagesCache) return enMessagesCache; - // eslint-disable-next-line @typescript-eslint/no-var-requires - enMessagesCache = require("../locales/en.json"); - return enMessagesCache as Messages; - }); +export function useI18nProvider(initialLocale: Locale = DEFAULT_LOCALE) { + const [locale, setLocaleState] = useState(initialLocale); + const [messages, setMessages] = useState(() => messagesByLocale[initialLocale]); const [ready, setReady] = useState(true); const changeLocale = useCallback((next: Locale) => { @@ -48,24 +50,25 @@ export function useI18nProvider() { .then((m) => { setMessages(m); setLocaleState(next); - if (typeof window !== "undefined") { - window.localStorage.setItem(storageKey, next); - } + persistLocale(next); setReady(true); }) .catch((err) => { console.warn("[i18n] failed to load locale, falling back to en", err); - if (enMessagesCache) setMessages(enMessagesCache); - setLocaleState("en"); + setMessages(messagesByLocale[DEFAULT_LOCALE]); + setLocaleState(DEFAULT_LOCALE); setReady(true); }); }, []); useEffect(() => { if (typeof window === "undefined") return; - const detected = detectLocale(); - changeLocale(detected); - }, [changeLocale]); + + const stored = window.localStorage.getItem(LOCALE_COOKIE); + if (isSupportedLocale(stored) && stored !== locale) { + changeLocale(stored); + } + }, [changeLocale, locale]); useEffect(() => { if (typeof document === "undefined") return; diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..0b9449d --- /dev/null +++ b/middleware.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { + DEFAULT_LOCALE, + LOCALE_COOKIE, + isSupportedLocale, + parseAcceptLanguage, + supportedLocales, +} from "./lib/i18n-core"; + +export function middleware(request: NextRequest) { + const response = NextResponse.next(); + const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value; + + if (!isSupportedLocale(cookieLocale)) { + const locale = parseAcceptLanguage( + request.headers.get("accept-language"), + supportedLocales, + DEFAULT_LOCALE + ); + + response.cookies.set(LOCALE_COOKIE, locale, { path: "/" }); + } + + return response; +} From 8d560dc2938caa68edc801f87bd90f80bcbd1c93 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:13:04 -0700 Subject: [PATCH 2/2] fix(i18n): respect Accept-Language q-values and scope middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address codex review on #126: - parseAcceptLanguage now parses q-values (default 1.0 per RFC 9110 §12.5.4), drops q=0 (explicit rejection), and selects the highest-q supported language. Previously a header like 'en;q=0.1, ar;q=1' picked en because the loop scanned in textual order and ignored q. - middleware.ts now exports a matcher config that excludes _next/static, _next/image, /api, common static asset extensions, and crawl files. Asset and API responses no longer attach a Set-Cookie header and remain cacheable. Typecheck still clean. --- lib/i18n-core.ts | 29 +++++++++++++++++++++++++---- middleware.ts | 10 ++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/i18n-core.ts b/lib/i18n-core.ts index 2d9669d..80063d6 100644 --- a/lib/i18n-core.ts +++ b/lib/i18n-core.ts @@ -19,14 +19,35 @@ export function parseAcceptLanguage( ): T { if (!header) return fallback; + // Parse "lang;q=0.5" entries, drop q=0 (explicit rejection), and pick + // the highest-q supported language. RFC 9110 §12.5.4: missing q + // defaults to 1.0. A header like "en;q=0.1, ar;q=1" must select ar. + const parsed: { tag: string; primary: string; q: number }[] = []; for (const part of header.split(",")) { - const tag = part.trim().split(";")[0]?.toLowerCase(); - const primary = tag?.split("-")[0]; + const segments = part.trim().split(";"); + const tag = segments[0]?.toLowerCase().trim(); + if (!tag) continue; + + let q = 1; + for (const param of segments.slice(1)) { + const [key, value] = param.split("=").map((s) => s.trim().toLowerCase()); + if (key === "q") { + const num = Number(value); + if (!Number.isNaN(num)) q = num; + } + } + + if (q <= 0) continue; + parsed.push({ tag, primary: tag.split("-")[0], q }); + } + + parsed.sort((a, b) => b.q - a.q); + + for (const entry of parsed) { const match = supported.find((locale) => { const normalized = locale.toLowerCase(); - return normalized === tag || normalized === primary; + return normalized === entry.tag || normalized === entry.primary; }); - if (match) return match; } diff --git a/middleware.ts b/middleware.ts index 0b9449d..284c84e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -24,3 +24,13 @@ export function middleware(request: NextRequest) { return response; } + +// Run only on routes that produce HTML or read the cookie. Skip Next.js +// internals, the optimized image endpoint, public static assets, and API +// routes — they don't need a locale cookie and we want their responses +// to stay cacheable. +export const config = { + matcher: [ + "/((?!_next/static|_next/image|api|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|woff|woff2|ttf|otf)$).*)", + ], +};