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)$).*)",
+ ],
+};