Skip to content

i18n: client-side locale store overrides server-detected locale, breaking automatic browser language detection #70

@priard

Description

@priard

The proxy correctly negotiates the locale from the Accept-Language header via createIntlMiddleware, but the client-side IntlProvider discards that decision in favor of the persisted Zustand store, which defaults to 'en'. As a result, first-time visitors with non-English browsers always see English UI even though the server initially resolved their preferred locale.

Reproduction

  1. Open the app in a clean browser (no localStorage) with Accept-Language: de-DE,de;q=0.9.
  2. The Next.js server, via proxy.tscreateIntlMiddleware(routing), correctly resolves the locale to de.
  3. app/[locale]/layout.tsx renders with locale="de" and passes it to IntlProvider as initialLocale.
  4. The user nevertheless sees the English UI.

Root cause

stores/locale-store.ts initializes the store with locale: 'en':

export const useLocaleStore = create<LocaleStore>()(
  persist(
    (set) => ({
      locale: 'en',          // <-- always defined, even on first visit
      setLocale: (locale) => set({ locale }),
    }),
    { name: 'locale-storage' }
  )
);

components/providers/intl-provider.tsx then prefers the store value over the prop:

const currentLocale = useLocaleStore((s) => s.locale);   // 'en' on first visit
const [activeLocale, setActiveLocale] = useState(
  currentLocale || initialLocale                          // 'en' || 'de' -> 'en'
);

useEffect(() => {
  if (!currentLocale) setLocale(initialLocale);           // never runs: 'en' is truthy
}, []);

useEffect(() => {
  if (currentLocale) setActiveLocale(currentLocale);      // re-applies 'en'
}, [currentLocale]);

Because Zustand's default is the truthy string 'en', the "sync initial locale on first mount" guard never fires, and the effect that mirrors currentLocale into activeLocale continually overwrites the server-detected locale.

The result is two correctly-implemented systems that fight each other: the server-side middleware does proper Accept-Language negotiation (with fallback to routing.defaultLocale), and the client-side store does proper persistence of the user's explicit choice — but the store cannot tell "user has not chosen yet" apart from "user chose English", so it always wins.

Expected behavior

  • On first visit with no persisted user choice, the locale resolved by proxy.ts (via Accept-Language) should win and be displayed.
  • Once a user explicitly picks a language via the language switcher, that choice should persist across visits and override Accept-Language on subsequent loads (current persistence behavior is correct in spirit).
  • README's "Automatic browser language detection" claim should match observable behavior.

Proposed fix

Distinguish "no user choice yet" (null) from "user chose English" ('en').

stores/locale-store.ts:

interface LocaleStore {
  locale: string | null;
  setLocale: (locale: string) => void;
}

export const useLocaleStore = create<LocaleStore>()(
  persist(
    (set) => ({
      locale: null,
      setLocale: (locale) => set({ locale }),
    }),
    { name: 'locale-storage' }
  )
);

No changes are needed in components/providers/intl-provider.tsx — the existing logic now works as intended:

  • useState(currentLocale || initialLocale) correctly falls back to the server-detected locale on first render (null || 'de''de').
  • if (!currentLocale) setLocale(initialLocale) correctly persists the detected locale on first mount.
  • The mirroring effect continues to apply the user's explicit choice on subsequent visits.

The only consumers of useLocaleStore are IntlProvider (handles falsy locale already) and LanguageSwitcher (only calls setLocale, never reads state.locale), so the type widening from string to string | null is safe.

Considerations

Migration for existing users

Users who visited the app before this fix already have 'en' written to localStorage under locale-storage, even if they never opened the language switcher. For those users, automatic detection will not retroactively kick in — they will need to manually pick a language once, or we ship a one-time migration that clears the stale default.

A minimal migration would inspect the persisted value and treat it as "unset" if it equals the previous hardcoded default ('en') and the user has never visited the language switcher. However, this is impossible to detect reliably without an extra flag, so the simplest path is to either:

  1. Accept the partial regression for existing users and document it in the release notes, or
  2. Bump the persisted store's version and write a migration that resets locale to null for everyone, forcing one re-detection on next visit.

URL prefix

i18n/routing.ts uses localePrefix: 'never', so the URL does not change with detection. After the fix, verify the OAuth callback in app/[locale]/login/page.tsx:276 still resolves to the correct locale segment for non-en users.

Hydration timing

Zustand's persist middleware hydrates synchronously from localStorage on the client. There is a brief window between server render and client hydration where currentLocale may be null even for returning users. The existing useState(currentLocale || initialLocale) initializer handles this correctly — it picks up the server-detected locale, then the useEffect([currentLocale]) re-syncs once hydration completes.

Refactor opportunity (optional)

IntlProvider currently manages locale via three useEffects, two of which exist only because the store cannot represent "not yet chosen." With locale: string | null, two of those effects can collapse into derived state. Out of scope for this fix, but worth a follow-up.

Acceptance criteria

  • Clean-profile visit with Accept-Language: de lands on the German UI without a flash of English.
  • Clean-profile visit with Accept-Language: xx (unsupported) falls back to en (handled by createIntlMiddleware and i18n/request.ts).
  • Manually picking a language in the switcher persists across reloads and survives a different Accept-Language on the next visit.
  • OAuth/OIDC login completes successfully under each supported locale.
  • Existing users with persisted 'en' either get a one-time migration or are documented as needing to pick their language manually once.
  • README's "Automatic browser language detection" claim matches reality.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions