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;