Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nextjs-i18n-ssr-language.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
EmbeddedFlowStatus,
HttpRequestConfig,
HttpResponse,
I18nPreferences,
} from '@asgardeo/node';
import {
I18nProvider,
Expand Down Expand Up @@ -75,6 +76,11 @@ export type AsgardeoClientProviderProps = Partial<Omit<AsgardeoProviderProps, 'b
) => Promise<{error?: string; redirectUrl?: string; success: boolean}>;
httpRequest?: (requestConfig: HttpRequestConfig) => Promise<HttpRequestActionResult>;
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<RefreshResult>;
Expand Down Expand Up @@ -118,10 +124,22 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
brandingPreference,
afterSignInUrl,
httpRequest,
language,
}: PropsWithChildren<AsgardeoClientProviderProps>) => {
const reRenderCheckRef: RefObject<boolean> = 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<boolean>(true);
const [user, setUser] = useState<User | null>(_user);
const [userProfile, setUserProfile] = useState<UserProfile>(_userProfile);
Expand Down Expand Up @@ -399,7 +417,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>

return (
<AsgardeoContext.Provider value={contextValue}>
<I18nProvider preferences={preferences?.i18n}>
<I18nProvider preferences={i18nPreferences}>
<BrandingProvider brandingPreference={brandingPreference}>
<ThemeProvider
theme={preferences?.theme?.overrides}
Expand Down
30 changes: 29 additions & 1 deletion packages/nextjs/src/server/AsgardeoProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,17 @@

'use server';

import {BrandingPreference, AsgardeoRuntimeError, IdToken, Organization, User, UserProfile} from '@asgardeo/node';
import {
BrandingPreference,
AsgardeoRuntimeError,
I18nPreferences,
IdToken,
Organization,
User,
UserProfile,
} from '@asgardeo/node';
import {AsgardeoProviderProps} from '@asgardeo/react';
import {cookies, headers} from 'next/headers';
import {FC, PropsWithChildren, ReactElement} from 'react';
import clearSession from './actions/clearSession';
import createOrganization from './actions/createOrganization';
Expand All @@ -44,8 +53,11 @@ import AsgardeoNextClient from '../AsgardeoNextClient';
import AsgardeoClientProvider from '../client/contexts/Asgardeo/AsgardeoProvider.js';
import {AsgardeoNextConfig} from '../models/config';
import logger from '../utils/logger';
import resolveRequestLanguage from '../utils/resolveRequestLanguage';
import {SessionTokenPayload} from '../utils/SessionManager';

const DEFAULT_I18N_STORAGE_KEY: string = 'asgardeo-i18n-language';

/**
* Props interface of {@link AsgardeoServerProvider}
*/
Expand Down Expand Up @@ -111,6 +123,21 @@ const AsgardeoServerProvider: FC<PropsWithChildren<AsgardeoServerProviderProps>>
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()) || '';
Expand Down Expand Up @@ -228,6 +255,7 @@ const AsgardeoServerProvider: FC<PropsWithChildren<AsgardeoServerProviderProps>>
afterSignInUrl={config?.afterSignInUrl}
httpRequest={httpRequestAction}
preferences={config?.preferences}
language={language}
clientId={config?.clientId}
user={user}
currentOrganization={currentOrganization}
Expand Down
42 changes: 42 additions & 0 deletions packages/nextjs/src/utils/__tests__/resolveRequestLanguage.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
53 changes: 53 additions & 0 deletions packages/nextjs/src/utils/resolveRequestLanguage.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading