From ea2522d8f77a50134f635d41e05d5421c013681b Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sun, 6 Sep 2026 13:33:50 +0530 Subject: [PATCH] fix(nextjs): resolve the UI language on the server so hydration does not fail The i18n provider detected the language from the browser and its cookie on the client only, while the server rendered en-US, so translated texts (e.g. the sign-in button label) produced hydration errors whenever the browser language, the persisted cookie or a `?lang=` parameter differed. - The server provider resolves the language the way the client would (persisted cookie, then Accept-Language) and passes it down. - The client provider adds the `lang` URL parameter, which both renders can see, and hands the result to the i18n provider unless preferences.i18n.language is configured explicitly. Co-Authored-By: Claude Fable 5.1 --- .changeset/nextjs-i18n-ssr-language.md | 5 ++ .../contexts/Asgardeo/AsgardeoProvider.tsx | 20 ++++++- .../nextjs/src/server/AsgardeoProvider.tsx | 30 ++++++++++- .../__tests__/resolveRequestLanguage.test.ts | 42 +++++++++++++++ .../src/utils/resolveRequestLanguage.ts | 53 +++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 .changeset/nextjs-i18n-ssr-language.md create mode 100644 packages/nextjs/src/utils/__tests__/resolveRequestLanguage.test.ts create mode 100644 packages/nextjs/src/utils/resolveRequestLanguage.ts diff --git a/.changeset/nextjs-i18n-ssr-language.md b/.changeset/nextjs-i18n-ssr-language.md new file mode 100644 index 000000000..61efbca4a --- /dev/null +++ b/.changeset/nextjs-i18n-ssr-language.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +Server and client renders now agree on the UI language, which fixes hydration errors on translated texts (for example the sign-in button label) whenever the browser language, the persisted language cookie or a `?lang=` parameter differed from `en-US`. The server resolves the language the way the client would detect it (persisted cookie, then `Accept-Language`), the client provider adds the `lang` URL parameter it can see, and the result is handed to the i18n provider unless `preferences.i18n.language` is configured explicitly. diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index de77065f8..1d5bfdac4 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -35,6 +35,7 @@ import { EmbeddedFlowStatus, HttpRequestConfig, HttpResponse, + I18nPreferences, } from '@asgardeo/node'; import { I18nProvider, @@ -75,6 +76,11 @@ export type AsgardeoClientProviderProps = Partial Promise<{error?: string; redirectUrl?: string; success: boolean}>; httpRequest?: (requestConfig: HttpRequestConfig) => Promise; isSignedIn: boolean; + /** + * UI language resolved on the server from the request (persisted cookie, then `Accept-Language`). + * Combined with the `lang` URL parameter here so the server and client renders agree on the language. + */ + language?: string; myOrganizations: Organization[]; organizationHandle: AsgardeoContextProps['organizationHandle']; refreshToken: () => Promise; @@ -118,10 +124,22 @@ const AsgardeoClientProvider: FC> brandingPreference, afterSignInUrl, httpRequest, + language, }: PropsWithChildren) => { const reRenderCheckRef: RefObject = useRef(false); const router: AppRouterInstance = useRouter(); const searchParams: ReadonlyURLSearchParams = useSearchParams(); + + // The i18n provider would detect the language from the browser and its cookie on the client only, which + // differs from the server render. Resolve it identically on both sides instead: an explicitly configured + // language, then the `lang` URL parameter (visible to both renders), then the language the server resolved + // from the request. + const i18nPreferences: I18nPreferences = useMemo(() => { + const urlParam: string | false = preferences?.i18n?.urlParam === undefined ? 'lang' : preferences.i18n.urlParam; + const languageFromUrl: string | null = urlParam === false ? null : searchParams.get(urlParam); + + return {...preferences?.i18n, language: preferences?.i18n?.language ?? languageFromUrl ?? language}; + }, [preferences?.i18n, searchParams, language]); const [isLoading, setIsLoading] = useState(true); const [user, setUser] = useState(_user); const [userProfile, setUserProfile] = useState(_userProfile); @@ -399,7 +417,7 @@ const AsgardeoClientProvider: FC> return ( - + > return <>; } + // Resolve the UI language on the server the way the client-side i18n provider detects it (persisted + // cookie, then the browser's Accept-Language), so both renders use the same translations and hydration + // does not fail on translated texts. An explicitly configured language always wins. + const i18nPreferences: I18nPreferences | undefined = config?.preferences?.i18n; + let language: string | undefined = i18nPreferences?.language; + + if (!language) { + const storedLanguage: string | undefined = + (i18nPreferences?.storageStrategy ?? 'cookie') === 'cookie' + ? (await cookies()).get(i18nPreferences?.storageKey ?? DEFAULT_I18N_STORAGE_KEY)?.value + : undefined; + + language = resolveRequestLanguage({acceptLanguage: (await headers()).get('accept-language'), storedLanguage}); + } + // Try to get session information from JWT first, then fall back to legacy const sessionPayload: SessionTokenPayload | undefined = await getSessionPayload(); const sessionId: string = sessionPayload?.sessionId || (await getSessionId()) || ''; @@ -228,6 +255,7 @@ const AsgardeoServerProvider: FC> afterSignInUrl={config?.afterSignInUrl} httpRequest={httpRequestAction} preferences={config?.preferences} + language={language} clientId={config?.clientId} user={user} currentOrganization={currentOrganization} diff --git a/packages/nextjs/src/utils/__tests__/resolveRequestLanguage.test.ts b/packages/nextjs/src/utils/__tests__/resolveRequestLanguage.test.ts new file mode 100644 index 000000000..5ba24c96d --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/resolveRequestLanguage.test.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, expect, it} from 'vitest'; +import resolveRequestLanguage from '../resolveRequestLanguage'; + +describe('resolveRequestLanguage', () => { + it('prefers the persisted language', () => { + expect(resolveRequestLanguage({acceptLanguage: 'fr-FR,fr;q=0.9', storedLanguage: 'de-DE'})).toBe('de-DE'); + }); + + it('falls back to the first language of the Accept-Language header', () => { + expect(resolveRequestLanguage({acceptLanguage: 'fr-FR,fr;q=0.9,en-US;q=0.8'})).toBe('fr-FR'); + expect(resolveRequestLanguage({acceptLanguage: ' en-GB ; q=0.7 , en'})).toBe('en-GB'); + }); + + it('ignores a wildcard and empty values', () => { + expect(resolveRequestLanguage({acceptLanguage: '*'})).toBeUndefined(); + expect(resolveRequestLanguage({acceptLanguage: '*, ta-IN'})).toBe('ta-IN'); + expect(resolveRequestLanguage({acceptLanguage: '', storedLanguage: ''})).toBeUndefined(); + }); + + it('returns undefined when nothing is known', () => { + expect(resolveRequestLanguage({})).toBeUndefined(); + expect(resolveRequestLanguage({acceptLanguage: null, storedLanguage: null})).toBeUndefined(); + }); +}); diff --git a/packages/nextjs/src/utils/resolveRequestLanguage.ts b/packages/nextjs/src/utils/resolveRequestLanguage.ts new file mode 100644 index 000000000..2c537080d --- /dev/null +++ b/packages/nextjs/src/utils/resolveRequestLanguage.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Inputs for {@link resolveRequestLanguage}. + */ +export interface ResolveRequestLanguageOptions { + /** The request's `Accept-Language` header, if any. */ + acceptLanguage?: string | null; + /** The language persisted by the i18n provider (its cookie), if any. */ + storedLanguage?: string | null; +} + +/** + * Resolves the UI language for a request the way the client-side i18n provider detects it: the persisted + * preference first, then the browser's preferred language (`Accept-Language` corresponds to + * `navigator.language`). Used on the server so that the server and client renders agree on the language + * and hydration does not fail on translated texts. + * + * @returns The language tag (e.g. `en-US`), or `undefined` when nothing can be resolved. + */ +const resolveRequestLanguage = ({ + storedLanguage, + acceptLanguage, +}: ResolveRequestLanguageOptions): string | undefined => { + if (storedLanguage) { + return storedLanguage; + } + + const preferred: string | undefined = acceptLanguage + ?.split(',') + .map((part: string) => part.trim().split(';')[0]?.trim() ?? '') + .find((tag: string) => tag !== '' && tag !== '*'); + + return preferred || undefined; +}; + +export default resolveRequestLanguage;