Skip to content

fix(i18n): server-side locale detection to remove flash of default language#132

Merged
O2sa merged 2 commits intoO2sa:mainfrom
mvanhorn:osc/126-server-side-locale-detection
Apr 27, 2026
Merged

fix(i18n): server-side locale detection to remove flash of default language#132
O2sa merged 2 commits intoO2sa:mainfrom
mvanhorn:osc/126-server-side-locale-detection

Conversation

@mvanhorn
Copy link
Copy Markdown
Contributor

Closes #126.

Problem

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. Result: flash of incorrect content + <html lang/dir> hydration mismatch.

Fix

Detect on the server using cookies + Accept-Language, write the cookie via middleware, and seed the React provider with the resolved locale so client detection no longer runs in useEffect.

Files

  • middleware.ts (new): on each request, if no valid app-locale cookie exists, parse Accept-Language and set the cookie in the response. <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 cookies() then fall through to headers() Accept-Language. Set <html lang> and <html dir> on the initial server render and pass initialLocale down.
  • app/providers.tsx, components/language-provider.tsx: forward initialLocale to useI18nProvider.
  • lib/i18n.ts: provider seeds initial state from initialLocale instead of running detection inside useState. When the user changes locale at runtime, both the cookie and localStorage are written so the next reload renders correctly without a flash. Re-exports the i18n-core surface so existing imports continue to work.

Existing localStorage-stored preferences are still honored; the cookie is now the SSR-friendly persistence and gets updated on every locale change so client + server converge.

Verification

npx tsc --noEmit is clean. The dev server hits <html lang="ar" dir="rtl"> on first response when Accept-Language: ar is present (cookie absent), and on every subsequent request after the cookie is set.

…nguage

Closes O2sa#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 `<html lang/dir>` 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 `<html lang>` and `<html dir>`
  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.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

@mvanhorn is attempting to deploy a commit to the osama's projects Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions
Copy link
Copy Markdown

Thank you for the pull request! ✅

A maintainer will review this soon. Please be patient while we take a look. 🙌

Address codex review on O2sa#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.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dev-impact Ready Ready Preview, Comment Apr 27, 2026 8:51pm

@O2sa O2sa merged commit f55c5be into O2sa:main Apr 27, 2026
2 checks passed
@github-actions
Copy link
Copy Markdown

Thank you, @mvanhorn! Another great contribution merged! 🚀

You've been a fantastic contributor! We truly appreciate your continued support.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: Flash of default language before applying user preference

2 participants