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
- Open the app in a clean browser (no
localStorage) with Accept-Language: de-DE,de;q=0.9.
- The Next.js server, via
proxy.ts → createIntlMiddleware(routing), correctly resolves the locale to de.
app/[locale]/layout.tsx renders with locale="de" and passes it to IntlProvider as initialLocale.
- 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:
- Accept the partial regression for existing users and document it in the release notes, or
- 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
The proxy correctly negotiates the locale from the
Accept-Languageheader viacreateIntlMiddleware, but the client-sideIntlProviderdiscards 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
localStorage) withAccept-Language: de-DE,de;q=0.9.proxy.ts→createIntlMiddleware(routing), correctly resolves the locale tode.app/[locale]/layout.tsxrenders withlocale="de"and passes it toIntlProviderasinitialLocale.Root cause
stores/locale-store.tsinitializes the store withlocale: 'en':components/providers/intl-provider.tsxthen prefers the store value over the prop:Because Zustand's default is the truthy string
'en', the "sync initial locale on first mount" guard never fires, and the effect that mirrorscurrentLocaleintoactiveLocalecontinually overwrites the server-detected locale.The result is two correctly-implemented systems that fight each other: the server-side middleware does proper
Accept-Languagenegotiation (with fallback torouting.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
proxy.ts(viaAccept-Language) should win and be displayed.Accept-Languageon subsequent loads (current persistence behavior is correct in spirit).Proposed fix
Distinguish "no user choice yet" (
null) from "user chose English" ('en').stores/locale-store.ts: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 only consumers of
useLocaleStoreareIntlProvider(handles falsy locale already) andLanguageSwitcher(only callssetLocale, never readsstate.locale), so the type widening fromstringtostring | nullis safe.Considerations
Migration for existing users
Users who visited the app before this fix already have
'en'written tolocalStorageunderlocale-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:localetonullfor everyone, forcing one re-detection on next visit.URL prefix
i18n/routing.tsuseslocalePrefix: 'never', so the URL does not change with detection. After the fix, verify the OAuth callback inapp/[locale]/login/page.tsx:276still resolves to the correct locale segment for non-enusers.Hydration timing
Zustand's
persistmiddleware hydrates synchronously fromlocalStorageon the client. There is a brief window between server render and client hydration wherecurrentLocalemay benulleven for returning users. The existinguseState(currentLocale || initialLocale)initializer handles this correctly — it picks up the server-detected locale, then theuseEffect([currentLocale])re-syncs once hydration completes.Refactor opportunity (optional)
IntlProvidercurrently manages locale via threeuseEffects, two of which exist only because the store cannot represent "not yet chosen." Withlocale: string | null, two of those effects can collapse into derived state. Out of scope for this fix, but worth a follow-up.Acceptance criteria
Accept-Language: delands on the German UI without a flash of English.Accept-Language: xx(unsupported) falls back toen(handled bycreateIntlMiddlewareandi18n/request.ts).Accept-Languageon the next visit.'en'either get a one-time migration or are documented as needing to pick their language manually once.