diff --git a/.changeset/plenty-dolls-pick.md b/.changeset/plenty-dolls-pick.md
new file mode 100644
index 00000000000..34031d577e6
--- /dev/null
+++ b/.changeset/plenty-dolls-pick.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': minor
+---
+
+Centralize redirect concerns for SignIn
diff --git a/integration/templates/next-app-router/next.config.js b/integration/templates/next-app-router/next.config.js
index e47b0b46969..05a4a80e6ab 100644
--- a/integration/templates/next-app-router/next.config.js
+++ b/integration/templates/next-app-router/next.config.js
@@ -1,9 +1,19 @@
+const path = require('path');
+
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
- outputFileTracingRoot: '/',
+ outputFileTracingRoot: path.resolve(__dirname, '../../../'),
+ webpack: config => {
+ // Exclude macOS .Trash directory and other system directories to prevent permission errors
+ config.watchOptions = {
+ ...config.watchOptions,
+ ignored: ['**/.Trash/**', '**/node_modules/**', '**/.git/**'],
+ };
+ return config;
+ },
};
module.exports = nextConfig;
diff --git a/integration/tests/session-tasks-multi-session.test.ts b/integration/tests/session-tasks-multi-session.test.ts
index da265d1f693..64f1ade0491 100644
--- a/integration/tests/session-tasks-multi-session.test.ts
+++ b/integration/tests/session-tasks-multi-session.test.ts
@@ -61,6 +61,11 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
// Create second user, to initiate a pending session
// Don't resolve task and switch to active session afterwards
await u.po.signIn.goTo();
+ await u.page.waitForURL(/sign-in\/choose/);
+ await u.page.getByText('Add account').click();
+ await u.page.waitForURL(/sign-in$/);
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.getIdentifierInput().waitFor({ state: 'visible' });
await u.po.signIn.setIdentifier(user2.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(user2.password);
diff --git a/integration/tests/sign-in-single-session-mode.test.ts b/integration/tests/sign-in-single-session-mode.test.ts
new file mode 100644
index 00000000000..821027d021d
--- /dev/null
+++ b/integration/tests/sign-in-single-session-mode.test.ts
@@ -0,0 +1,79 @@
+import type { FakeUser } from '@clerk/testing/playwright';
+import { test } from '@playwright/test';
+
+import type { Application } from '../models/application';
+import { appConfigs } from '../presets';
+import { createTestUtils, testAgainstRunningApps } from '../testUtils';
+
+/**
+ * Tests for single-session mode behavior using the withBilling environment
+ * which is configured for single-session mode in the Clerk Dashboard.
+ */
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })(
+ 'sign in with active session in single-session mode @generic @nextjs',
+ ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser({
+ withPhoneNumber: true,
+ withUsername: true,
+ });
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ });
+
+ test('redirects to afterSignIn URL when visiting /sign-in with active session in single-session mode', async ({
+ page,
+ context,
+ }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.waitForAppUrl('/');
+
+ await u.po.signIn.goTo();
+ await u.page.waitForAppUrl('/');
+ await u.po.expect.toBeSignedIn();
+ });
+
+ test('does NOT show account switcher in single-session mode', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ await u.page.goToRelative('/sign-in/choose');
+ await u.page.waitForAppUrl('/');
+ });
+
+ test('shows sign-in form when no active session in single-session mode', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.page.context().clearCookies();
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.page.waitForSelector('text=/email address|username|phone/i');
+ await u.page.waitForURL(/sign-in$/);
+ });
+
+ test('can sign in normally when not already authenticated in single-session mode', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.page.context().clearCookies();
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.waitForAppUrl('/');
+ });
+ },
+);
diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx
index 2ba67af8659..a2d7a2ef319 100644
--- a/packages/ui/src/components/SignIn/SignInStart.tsx
+++ b/packages/ui/src/components/SignIn/SignInStart.tsx
@@ -11,31 +11,20 @@ import type {
SignInResource,
} from '@clerk/shared/types';
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn';
-import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
-
-import { Card } from '@/ui/elements/Card';
-import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
-import { Form } from '@/ui/elements/Form';
-import { Header } from '@/ui/elements/Header';
-import { LoadingCard } from '@/ui/elements/LoadingCard';
-import { SocialButtonsReversibleContainerWithDivider } from '@/ui/elements/ReversibleContainer';
-import { handleError } from '@/ui/utils/errorHandler';
-import { isMobileDevice } from '@/ui/utils/isMobileDevice';
-import type { FormControlState } from '@/ui/utils/useFormControl';
-import { buildRequest, useFormControl } from '@/ui/utils/useFormControl';
+import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { SignInStartIdentifier } from '../../common';
-import {
- buildSSOCallbackURL,
- getIdentifierControlDisplayValues,
- groupIdentifiers,
- withRedirectToAfterSignIn,
- withRedirectToSignInTask,
-} from '../../common';
+import { buildSSOCallbackURL, getIdentifierControlDisplayValues, groupIdentifiers } from '../../common';
import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts';
import { Col, descriptors, Flow, localizationKeys } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
-import { useLoadingStatus } from '../../hooks';
+import { Card } from '../../elements/Card';
+import { useCardState, withCardStateProvider } from '../../elements/contexts';
+import { Form } from '../../elements/Form';
+import { Header } from '../../elements/Header';
+import { LoadingCard } from '../../elements/LoadingCard';
+import { SocialButtonsReversibleContainerWithDivider } from '../../elements/ReversibleContainer';
+import { useLoadingStatus, useSignInRedirect } from '../../hooks';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods';
import { useRouter } from '../../router';
@@ -48,6 +37,10 @@ import {
getPreferredAlternativePhoneChannelForCombinedFlow,
getSignUpAttributeFromIdentifier,
} from './utils';
+import { handleError } from '@/ui/utils/errorHandler';
+import { isMobileDevice } from '@/ui/utils/isMobileDevice';
+import type { FormControlState } from '@/ui/utils/useFormControl';
+import { buildRequest, useFormControl } from '@/ui/utils/useFormControl';
const useAutoFillPasskey = () => {
const [isSupported, setIsSupported] = useState(false);
@@ -523,7 +516,12 @@ function SignInStartInternal(): JSX.Element {
return components[identifierField.type as keyof typeof components];
}, [identifierField.type]);
- if (status.isLoading || clerkStatus === 'sign_up') {
+ const { isRedirecting } = useSignInRedirect({
+ afterSignInUrl,
+ organizationTicket,
+ });
+
+ if (isRedirecting || status.isLoading || clerkStatus === 'sign_up') {
// clerkStatus being sign_up will trigger a navigation to the sign up flow, so show a loading card instead of
// rendering the sign in flow.
return ;
@@ -708,6 +706,4 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> })
);
};
-export const SignInStart = withRedirectToSignInTask(
- withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)),
-);
+export const SignInStart = withCardStateProvider(SignInStartInternal);
diff --git a/packages/ui/src/components/UserButton/useMultisessionActions.tsx b/packages/ui/src/components/UserButton/useMultisessionActions.tsx
index 709fd29ecce..f4f5109244c 100644
--- a/packages/ui/src/components/UserButton/useMultisessionActions.tsx
+++ b/packages/ui/src/components/UserButton/useMultisessionActions.tsx
@@ -3,6 +3,7 @@ import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { useClerk } from '@clerk/shared/react';
import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types';
+import { buildURL } from '@clerk/shared/internal/clerk-js/url';
import { useEnvironment } from '@/ui/contexts';
import { useCardState } from '@/ui/elements/contexts';
import { sleep } from '@/ui/utils/sleep';
@@ -25,7 +26,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
const { setActive, signOut, openUserProfile } = useClerk();
const card = useCardState();
const { signedInSessions, otherSessions } = useMultipleSessions({ user: opts.user });
- const { navigate } = useRouter();
+ const { navigate, queryParams } = useRouter();
const { displayConfig } = useEnvironment();
const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => {
@@ -99,7 +100,15 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
};
const handleAddAccountClicked = () => {
- windowNavigate(opts.signInUrl || window.location.href);
+ const baseUrl = opts.signInUrl || (typeof window !== 'undefined' ? window.location.href : '');
+ const url = buildURL(
+ {
+ base: baseUrl,
+ searchParams: new URLSearchParams({ ...queryParams, __clerk_add_account: 'true' }),
+ },
+ { stringify: true },
+ );
+ windowNavigate(url);
return sleep(2000);
};
diff --git a/packages/ui/src/hooks/__tests__/useRedirectEngine.test.tsx b/packages/ui/src/hooks/__tests__/useRedirectEngine.test.tsx
new file mode 100644
index 00000000000..b9fd66e993e
--- /dev/null
+++ b/packages/ui/src/hooks/__tests__/useRedirectEngine.test.tsx
@@ -0,0 +1,198 @@
+import type { Clerk, EnvironmentResource } from '@clerk/types';
+import { renderHook, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useRedirectEngine } from '../useRedirectEngine';
+
+// Mock dependencies
+vi.mock('@clerk/shared/react', () => ({
+ useClerk: vi.fn(),
+}));
+
+vi.mock('../../contexts', () => ({
+ useEnvironment: vi.fn(),
+}));
+
+vi.mock('../../router', () => ({
+ useRouter: vi.fn(),
+}));
+
+vi.mock('../../utils/redirectRules', async () => {
+ const actual = await vi.importActual('../../utils/redirectRules');
+ return {
+ ...actual,
+ evaluateRedirectRules: vi.fn(),
+ };
+});
+
+import { useClerk } from '@clerk/shared/react';
+
+import { useEnvironment } from '../../contexts';
+import { useRouter } from '../../router';
+import { evaluateRedirectRules } from '../../utils/redirectRules';
+
+describe('useRedirectEngine', () => {
+ const mockNavigate = vi.fn();
+ const mockClerk = {
+ isSignedIn: false,
+ client: { sessions: [], signedInSessions: [] },
+ } as unknown as Clerk;
+ const mockEnvironment = {
+ authConfig: { singleSessionMode: true },
+ } as EnvironmentResource;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (useClerk as any).mockReturnValue(mockClerk);
+ (useEnvironment as any).mockReturnValue(mockEnvironment);
+ (useRouter as any).mockReturnValue({ navigate: mockNavigate, currentPath: '/sign-in' });
+ (evaluateRedirectRules as any).mockReturnValue(null);
+ });
+
+ it('returns isRedirecting false when no redirect needed', () => {
+ const { result } = renderHook(() =>
+ useRedirectEngine({
+ rules: [],
+ additionalContext: {},
+ }),
+ );
+
+ expect(result.current.isRedirecting).toBe(false);
+ });
+
+ it('navigates when redirect rule matches', async () => {
+ (evaluateRedirectRules as any).mockReturnValue({
+ destination: '/dashboard',
+ reason: 'Test redirect',
+ });
+
+ const { result } = renderHook(() =>
+ useRedirectEngine({
+ rules: [],
+ additionalContext: {},
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.isRedirecting).toBe(true);
+ expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
+ });
+ });
+
+ it('does not navigate when skipNavigation is true', async () => {
+ (evaluateRedirectRules as any).mockReturnValue({
+ destination: '/current',
+ reason: 'Side effect only',
+ skipNavigation: true,
+ });
+
+ renderHook(() =>
+ useRedirectEngine({
+ rules: [],
+ additionalContext: {},
+ }),
+ );
+
+ await waitFor(() => {
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
+
+ it('handles cleanupQueryParams declaratively', async () => {
+ const mockReplaceState = vi.fn();
+ Object.defineProperty(window, 'history', {
+ value: { replaceState: mockReplaceState },
+ writable: true,
+ });
+ Object.defineProperty(window, 'location', {
+ value: { search: '?__clerk_add_account=true&other=value', pathname: '/sign-in' },
+ writable: true,
+ });
+
+ (evaluateRedirectRules as any).mockReturnValue({
+ destination: '/sign-in',
+ reason: 'Cleanup params',
+ skipNavigation: true,
+ cleanupQueryParams: ['__clerk_add_account'],
+ });
+
+ renderHook(() =>
+ useRedirectEngine({
+ rules: [],
+ additionalContext: {},
+ }),
+ );
+
+ await waitFor(() => {
+ expect(mockReplaceState).toHaveBeenCalledWith({}, '', '/sign-in?other=value');
+ });
+ });
+
+ it('passes additional context to evaluateRedirectRules', async () => {
+ const additionalContext = {
+ afterSignInUrl: '/custom',
+ organizationTicket: 'test_ticket',
+ };
+
+ renderHook(() =>
+ useRedirectEngine({
+ rules: [],
+ additionalContext,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(evaluateRedirectRules).toHaveBeenCalledWith(
+ [],
+ expect.objectContaining({
+ clerk: mockClerk,
+ currentPath: '/sign-in',
+ environment: mockEnvironment,
+ ...additionalContext,
+ }),
+ );
+ });
+ });
+
+ it('re-evaluates when auth state changes', async () => {
+ const { rerender } = renderHook(() =>
+ useRedirectEngine({
+ rules: [],
+ additionalContext: {},
+ }),
+ );
+
+ expect(evaluateRedirectRules).toHaveBeenCalledTimes(1);
+
+ // Change auth state
+ (useClerk as any).mockReturnValue({
+ ...mockClerk,
+ isSignedIn: true,
+ });
+
+ rerender();
+
+ await waitFor(() => {
+ expect(evaluateRedirectRules).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('handles type-safe additional context', () => {
+ interface CustomContext {
+ customField: string;
+ optionalField?: number;
+ }
+
+ const { result } = renderHook(() =>
+ useRedirectEngine({
+ rules: [],
+ additionalContext: {
+ customField: 'test',
+ optionalField: 42,
+ },
+ }),
+ );
+
+ expect(result.current.isRedirecting).toBe(false);
+ });
+});
diff --git a/packages/ui/src/hooks/__tests__/useSignInRedirect.test.tsx b/packages/ui/src/hooks/__tests__/useSignInRedirect.test.tsx
new file mode 100644
index 00000000000..a6504f4113a
--- /dev/null
+++ b/packages/ui/src/hooks/__tests__/useSignInRedirect.test.tsx
@@ -0,0 +1,72 @@
+import { renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useSignInRedirect } from '../useSignInRedirect';
+
+vi.mock('../useRedirectEngine', () => ({
+ useRedirectEngine: vi.fn(() => ({ isRedirecting: false })),
+}));
+
+vi.mock('../../router', () => ({
+ useRouter: vi.fn(() => ({ queryParams: {} })),
+}));
+
+vi.mock('../../utils/redirectRules', () => ({
+ signInRedirectRules: [],
+}));
+
+import { useRouter } from '../../router';
+import { useRedirectEngine } from '../useRedirectEngine';
+
+describe('useSignInRedirect', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (useRouter as any).mockReturnValue({ queryParams: { test: 'value' } });
+ });
+
+ it('calls useRedirectEngine with signInRedirectRules', () => {
+ renderHook(() =>
+ useSignInRedirect({
+ afterSignInUrl: '/dashboard',
+ organizationTicket: 'test_ticket',
+ }),
+ );
+
+ expect(useRedirectEngine).toHaveBeenCalledWith({
+ rules: [],
+ additionalContext: expect.objectContaining({
+ afterSignInUrl: '/dashboard',
+ organizationTicket: 'test_ticket',
+ queryParams: { test: 'value' },
+ hasInitializedRef: expect.objectContaining({ current: expect.any(Boolean) }),
+ }),
+ });
+ });
+
+ it('returns isRedirecting from useRedirectEngine', () => {
+ (useRedirectEngine as any).mockReturnValue({ isRedirecting: true });
+
+ const { result } = renderHook(() =>
+ useSignInRedirect({
+ afterSignInUrl: '/dashboard',
+ }),
+ );
+
+ expect(result.current.isRedirecting).toBe(true);
+ });
+
+ it('sets hasInitializedRef to true after first render', () => {
+ const { rerender } = renderHook(() =>
+ useSignInRedirect({
+ afterSignInUrl: '/dashboard',
+ }),
+ );
+
+ const [[firstCall]] = (useRedirectEngine as any).mock.calls;
+ const ref = firstCall.additionalContext.hasInitializedRef;
+
+ // After first render, ref should be true
+ rerender();
+ expect(ref.current).toBe(true);
+ });
+});
diff --git a/packages/ui/src/hooks/__tests__/useSignUpRedirect.test.tsx b/packages/ui/src/hooks/__tests__/useSignUpRedirect.test.tsx
new file mode 100644
index 00000000000..d1ad75f4303
--- /dev/null
+++ b/packages/ui/src/hooks/__tests__/useSignUpRedirect.test.tsx
@@ -0,0 +1,65 @@
+import { renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useSignUpRedirect } from '../useSignUpRedirect';
+
+vi.mock('../useRedirectEngine', () => ({
+ useRedirectEngine: vi.fn(() => ({ isRedirecting: false })),
+}));
+
+vi.mock('../../router', () => ({
+ useRouter: vi.fn(() => ({ queryParams: {} })),
+}));
+
+vi.mock('../../utils/redirectRules', () => ({
+ signUpRedirectRules: [],
+}));
+
+import { useRouter } from '../../router';
+import { useRedirectEngine } from '../useRedirectEngine';
+
+describe('useSignUpRedirect', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (useRouter as any).mockReturnValue({ queryParams: { test: 'value' } });
+ });
+
+ it('calls useRedirectEngine with signUpRedirectRules', () => {
+ renderHook(() =>
+ useSignUpRedirect({
+ afterSignUpUrl: '/onboarding',
+ }),
+ );
+
+ expect(useRedirectEngine).toHaveBeenCalledWith({
+ rules: [],
+ additionalContext: expect.objectContaining({
+ afterSignUpUrl: '/onboarding',
+ queryParams: { test: 'value' },
+ }),
+ });
+ });
+
+ it('returns isRedirecting from useRedirectEngine', () => {
+ (useRedirectEngine as any).mockReturnValue({ isRedirecting: true });
+
+ const { result } = renderHook(() =>
+ useSignUpRedirect({
+ afterSignUpUrl: '/onboarding',
+ }),
+ );
+
+ expect(result.current.isRedirecting).toBe(true);
+ });
+
+ it('does not include hasInitializedRef for SignUp flow', () => {
+ renderHook(() =>
+ useSignUpRedirect({
+ afterSignUpUrl: '/onboarding',
+ }),
+ );
+
+ const [[call]] = (useRedirectEngine as any).mock.calls;
+ expect(call.additionalContext.hasInitializedRef).toBeUndefined();
+ });
+});
diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts
index 7b531d210e4..d7e33527995 100644
--- a/packages/ui/src/hooks/index.ts
+++ b/packages/ui/src/hooks/index.ts
@@ -16,5 +16,7 @@ export * from './usePrefersReducedMotion';
export * from './useSafeState';
export * from './useScrollLock';
export * from './useSearchInput';
+export * from './useSignInRedirect';
+export * from './useSignUpRedirect';
export * from './useTotalEnabledAuthMethods';
export * from './useWindowEventListener';
diff --git a/packages/ui/src/hooks/useRedirectEngine.ts b/packages/ui/src/hooks/useRedirectEngine.ts
new file mode 100644
index 00000000000..4735ec9cb9c
--- /dev/null
+++ b/packages/ui/src/hooks/useRedirectEngine.ts
@@ -0,0 +1,68 @@
+import { useClerk } from '@clerk/shared/react';
+import React from 'react';
+
+import { useEnvironment } from '../contexts';
+import { useRouter } from '../router';
+import type { RedirectContext, RedirectRule } from '../utils/redirectRules';
+import { evaluateRedirectRules } from '../utils/redirectRules';
+
+interface UseRedirectEngineOptions = Record> {
+ additionalContext?: C;
+ rules: RedirectRule[];
+}
+
+interface UseRedirectEngineReturn {
+ isRedirecting: boolean;
+}
+
+/**
+ * Internal redirect engine - use dedicated hooks like useSignInRedirect instead
+ * @internal
+ */
+export function useRedirectEngine = Record>(
+ options: UseRedirectEngineOptions,
+): UseRedirectEngineReturn {
+ const clerk = useClerk();
+ const environment = useEnvironment();
+ const { navigate, currentPath } = useRouter();
+ const [isRedirecting, setIsRedirecting] = React.useState(false);
+
+ React.useEffect(() => {
+ const context = {
+ clerk,
+ currentPath,
+ environment,
+ ...options.additionalContext,
+ } as RedirectContext & C;
+
+ const result = evaluateRedirectRules(options.rules, context);
+
+ if (result) {
+ if (result.cleanupQueryParams && typeof window !== 'undefined' && window.history) {
+ const params = new URLSearchParams(window.location.search);
+ result.cleanupQueryParams.forEach(param => params.delete(param));
+ const newSearch = params.toString();
+ const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '');
+ window.history.replaceState({}, '', newUrl);
+ }
+
+ if (!result.skipNavigation) {
+ setIsRedirecting(true);
+ void navigate(result.destination);
+ }
+ } else {
+ setIsRedirecting(false);
+ }
+ }, [
+ clerk.isSignedIn,
+ clerk.client?.sessions?.length,
+ clerk.client?.signedInSessions?.length,
+ currentPath,
+ environment.authConfig.singleSessionMode,
+ navigate,
+ options.additionalContext,
+ options.rules,
+ ]);
+
+ return { isRedirecting };
+}
diff --git a/packages/ui/src/hooks/useSignInRedirect.ts b/packages/ui/src/hooks/useSignInRedirect.ts
new file mode 100644
index 00000000000..9444fc5509a
--- /dev/null
+++ b/packages/ui/src/hooks/useSignInRedirect.ts
@@ -0,0 +1,38 @@
+import { useEffect, useMemo, useRef } from 'react';
+
+import { useRouter } from '../router';
+import { signInRedirectRules } from '../utils/redirectRules';
+import { useRedirectEngine } from './useRedirectEngine';
+
+export interface UseSignInRedirectOptions {
+ afterSignInUrl?: string;
+ organizationTicket?: string;
+}
+
+export interface UseSignInRedirectReturn {
+ isRedirecting: boolean;
+}
+
+export function useSignInRedirect(options: UseSignInRedirectOptions): UseSignInRedirectReturn {
+ const hasInitializedRef = useRef(false);
+ const { queryParams } = useRouter();
+
+ useEffect(() => {
+ hasInitializedRef.current = true;
+ }, []);
+
+ const additionalContext = useMemo(
+ () => ({
+ afterSignInUrl: options.afterSignInUrl,
+ hasInitializedRef,
+ organizationTicket: options.organizationTicket,
+ queryParams,
+ }),
+ [options.afterSignInUrl, options.organizationTicket, queryParams],
+ );
+
+ return useRedirectEngine({
+ rules: signInRedirectRules,
+ additionalContext,
+ });
+}
diff --git a/packages/ui/src/hooks/useSignUpRedirect.ts b/packages/ui/src/hooks/useSignUpRedirect.ts
new file mode 100644
index 00000000000..13737090523
--- /dev/null
+++ b/packages/ui/src/hooks/useSignUpRedirect.ts
@@ -0,0 +1,30 @@
+import { useMemo } from 'react';
+
+import { useRouter } from '../router';
+import { signUpRedirectRules } from '../utils/redirectRules';
+import { useRedirectEngine } from './useRedirectEngine';
+
+export interface UseSignUpRedirectOptions {
+ afterSignUpUrl?: string;
+}
+
+export interface UseSignUpRedirectReturn {
+ isRedirecting: boolean;
+}
+
+export function useSignUpRedirect(options: UseSignUpRedirectOptions): UseSignUpRedirectReturn {
+ const { queryParams } = useRouter();
+
+ const additionalContext = useMemo(
+ () => ({
+ afterSignUpUrl: options.afterSignUpUrl,
+ queryParams,
+ }),
+ [options.afterSignUpUrl, queryParams],
+ );
+
+ return useRedirectEngine({
+ rules: signUpRedirectRules,
+ additionalContext,
+ });
+}
diff --git a/packages/ui/src/utils/__tests__/redirectRules.test.ts b/packages/ui/src/utils/__tests__/redirectRules.test.ts
new file mode 100644
index 00000000000..d611803fe74
--- /dev/null
+++ b/packages/ui/src/utils/__tests__/redirectRules.test.ts
@@ -0,0 +1,318 @@
+import type { Clerk, EnvironmentResource } from '@clerk/types';
+import { describe, expect, it } from 'vitest';
+
+import type { RedirectContext } from '../redirectRules';
+import { evaluateRedirectRules, signInRedirectRules } from '../redirectRules';
+
+describe('evaluateRedirectRules', () => {
+ it('returns null when no rules match', () => {
+ const context: RedirectContext = {
+ clerk: { isSignedIn: false } as Clerk,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: true },
+ } as EnvironmentResource,
+ };
+
+ const result = evaluateRedirectRules([], context);
+ expect(result).toBeNull();
+ });
+
+ it('returns the first matching rule', () => {
+ const rules = [
+ () => [null, false] as const,
+ () => [{ destination: '/first', reason: 'First rule' }, false] as const,
+ () => [{ destination: '/second', reason: 'Second rule' }, false] as const,
+ ];
+
+ const context: RedirectContext = {
+ clerk: {} as Clerk,
+ currentPath: '/sign-in',
+ environment: {} as EnvironmentResource,
+ };
+
+ const result = evaluateRedirectRules(rules, context);
+ expect(result).toEqual({ destination: '/first', reason: 'First rule' });
+ });
+
+ it('handles shouldStop flag and returns null', () => {
+ const rules = [
+ () => [null, true] as const,
+ () => [{ destination: '/should-not-reach', reason: 'Should not execute' }, false] as const,
+ ];
+
+ const context: RedirectContext = {
+ clerk: {} as Clerk,
+ currentPath: '/sign-in',
+ environment: {} as EnvironmentResource,
+ };
+
+ const result = evaluateRedirectRules(rules, context);
+ expect(result).toBeNull();
+ });
+});
+
+describe('signInRedirectRules', () => {
+ describe('organization ticket guard', () => {
+ it('stops evaluation when organization ticket is present', () => {
+ const context: RedirectContext = {
+ clerk: {
+ buildAfterSignInUrl: () => '/dashboard',
+ isSignedIn: true,
+ } as Clerk,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: true },
+ } as EnvironmentResource,
+ organizationTicket: 'test_ticket',
+ afterSignInUrl: '/custom',
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+ // Should return null because guard stops evaluation
+ expect(result).toBeNull();
+ });
+
+ it('continues evaluation when no organization ticket', () => {
+ const context: RedirectContext = {
+ clerk: {
+ buildAfterSignInUrl: () => '/dashboard',
+ isSignedIn: true,
+ } as Clerk,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: true },
+ } as EnvironmentResource,
+ afterSignInUrl: '/custom',
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+ // Should match single session rule
+ expect(result).toEqual({
+ destination: '/custom',
+ reason: 'User already signed in (single session mode)',
+ });
+ });
+ });
+
+ describe('single session mode redirect', () => {
+ it('redirects to afterSignInUrl when already signed in', () => {
+ const context: RedirectContext = {
+ clerk: {
+ buildAfterSignInUrl: () => '/default',
+ isSignedIn: true,
+ } as Clerk,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: true },
+ } as EnvironmentResource,
+ afterSignInUrl: '/dashboard',
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+
+ expect(result).toEqual({
+ destination: '/dashboard',
+ reason: 'User already signed in (single session mode)',
+ });
+ });
+
+ it('uses clerk.buildAfterSignInUrl when afterSignInUrl not provided', () => {
+ const context: RedirectContext = {
+ clerk: {
+ buildAfterSignInUrl: () => '/default',
+ isSignedIn: true,
+ } as Clerk,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: true },
+ } as EnvironmentResource,
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+
+ expect(result).toEqual({
+ destination: '/default',
+ reason: 'User already signed in (single session mode)',
+ });
+ });
+
+ it('does not redirect when not signed in', () => {
+ const context: RedirectContext = {
+ clerk: {
+ buildAfterSignInUrl: () => '/default',
+ isSignedIn: false,
+ } as Clerk,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: true },
+ } as EnvironmentResource,
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+ expect(result).toBeNull();
+ });
+
+ it('does not redirect in multi-session mode', () => {
+ const context: RedirectContext = {
+ clerk: {
+ buildAfterSignInUrl: () => '/default',
+ isSignedIn: true,
+ client: { signedInSessions: [] },
+ } as any,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: false },
+ } as EnvironmentResource,
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+ // Should not match single session rule, should evaluate other rules
+ expect(result).not.toEqual(expect.objectContaining({ reason: 'User already signed in (single session mode)' }));
+ });
+ });
+
+ describe('multi-session mode account switcher redirect', () => {
+ it('redirects to /sign-in/choose at root with active sessions', () => {
+ const context: RedirectContext = {
+ clerk: {
+ client: { sessions: [{ id: '1' }, { id: '2' }], signedInSessions: [{ id: '1' }, { id: '2' }] },
+ isSignedIn: true,
+ } as any,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: false },
+ } as EnvironmentResource,
+ hasInitializedRef: { current: false },
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+
+ expect(result).toEqual({
+ destination: 'choose',
+ reason: 'Active sessions detected (multi-session mode)',
+ });
+ });
+
+ it('redirects to /sign-in/choose at root with trailing slash', () => {
+ const context: RedirectContext = {
+ clerk: {
+ client: { sessions: [{ id: '1' }], signedInSessions: [{ id: '1' }] },
+ isSignedIn: true,
+ } as any,
+ currentPath: '/sign-in/',
+ environment: {
+ authConfig: { singleSessionMode: false },
+ } as EnvironmentResource,
+ hasInitializedRef: { current: false },
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+
+ expect(result).toEqual({
+ destination: 'choose',
+ reason: 'Active sessions detected (multi-session mode)',
+ });
+ });
+
+ it('does not redirect when already initialized', () => {
+ const context: RedirectContext = {
+ clerk: {
+ client: { sessions: [{ id: '1' }], signedInSessions: [{ id: '1' }] },
+ isSignedIn: true,
+ } as any,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: false },
+ } as EnvironmentResource,
+ hasInitializedRef: { current: true },
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+ expect(result).toBeNull();
+ });
+
+ it('does not redirect when no active sessions', () => {
+ const context: RedirectContext = {
+ clerk: {
+ client: { sessions: [] },
+ isSignedIn: false,
+ } as any,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: false },
+ } as EnvironmentResource,
+ hasInitializedRef: { current: false },
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('rule priority', () => {
+ it('single session mode takes precedence over multi-session when both conditions met', () => {
+ const context: RedirectContext = {
+ clerk: {
+ buildAfterSignInUrl: () => '/dashboard',
+ client: { sessions: [{ id: '1' }], signedInSessions: [{ id: '1' }] },
+ isSignedIn: true,
+ } as any,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: true },
+ } as EnvironmentResource,
+ hasInitializedRef: { current: false },
+ afterSignInUrl: '/custom',
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+ // Should match first rule instead (single session redirect)
+ expect(result?.reason).toBe('User already signed in (single session mode)');
+ });
+ });
+
+ describe('add account flow', () => {
+ it('returns skip navigation when __clerk_add_account query param is present', () => {
+ const context: RedirectContext = {
+ clerk: {
+ client: { sessions: [], signedInSessions: [] },
+ isSignedIn: false,
+ } as any,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: false },
+ } as EnvironmentResource,
+ queryParams: {
+ __clerk_add_account: 'true',
+ },
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+ expect(result).toMatchObject({
+ destination: '/sign-in',
+ reason: 'User is adding account',
+ skipNavigation: true,
+ cleanupQueryParams: ['__clerk_add_account'],
+ });
+ });
+
+ it('does not skip navigation when __clerk_add_account query param is absent', () => {
+ const context: RedirectContext = {
+ clerk: {
+ client: { sessions: [], signedInSessions: [] },
+ isSignedIn: false,
+ } as any,
+ currentPath: '/sign-in',
+ environment: {
+ authConfig: { singleSessionMode: false },
+ } as EnvironmentResource,
+ queryParams: {},
+ };
+
+ const result = evaluateRedirectRules(signInRedirectRules, context);
+ // Should evaluate other rules
+ expect(result?.reason).not.toBe('User is adding account');
+ });
+ });
+});
diff --git a/packages/ui/src/utils/redirectRules.ts b/packages/ui/src/utils/redirectRules.ts
new file mode 100644
index 00000000000..34f276a7da4
--- /dev/null
+++ b/packages/ui/src/utils/redirectRules.ts
@@ -0,0 +1,170 @@
+import type { Clerk, EnvironmentResource } from '@clerk/types';
+import type React from 'react';
+
+const debugLogger = {
+ info: (..._args: unknown[]) => {},
+};
+
+export interface RedirectContext {
+ readonly afterSignInUrl?: string;
+ readonly afterSignUpUrl?: string;
+ readonly clerk: Clerk;
+ readonly currentPath: string;
+ readonly environment: EnvironmentResource;
+ readonly hasInitializedRef?: React.MutableRefObject;
+ readonly organizationTicket?: string;
+ readonly queryParams?: Record;
+}
+
+export interface RedirectResult {
+ readonly destination: string;
+ readonly reason: string;
+ readonly skipNavigation?: boolean;
+ readonly cleanupQueryParams?: string[];
+}
+
+export type RedirectRule = Record> = (
+ context: RedirectContext & T,
+) => readonly [RedirectResult | null, boolean];
+
+function isValidRedirectUrl(url: string): boolean {
+ try {
+ if (url.startsWith('/')) {
+ return true;
+ }
+ const parsed = new URL(url, window.location.origin);
+ return parsed.origin === window.location.origin;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Evaluates redirect rules in order, returning the first match.
+ *
+ * @param rules - Array of redirect rules to evaluate
+ * @param context - Context containing clerk instance, path, environment, etc.
+ * @returns The first matching redirect result, or null if no rules match
+ */
+export function evaluateRedirectRules = Record>(
+ rules: RedirectRule[],
+ context: RedirectContext & T,
+): RedirectResult | null {
+ for (const rule of rules) {
+ const [result, shouldStop] = rule(context);
+
+ if (shouldStop) {
+ debugLogger.info('Redirect evaluation stopped', { reason: 'Guard triggered' }, 'redirect');
+ return null;
+ }
+
+ if (result) {
+ debugLogger.info(
+ 'Redirect rule matched',
+ {
+ destination: result.destination,
+ reason: result.reason,
+ },
+ 'redirect',
+ );
+ return result;
+ }
+ }
+ return null;
+}
+
+/**
+ * Redirect rules for SignIn component
+ */
+export const signInRedirectRules: RedirectRule[] = [
+ // Guard: Organization ticket flow is handled separately in component
+ ctx => {
+ if (ctx.organizationTicket) {
+ return [null, true];
+ }
+ return [null, false];
+ },
+
+ // Rule 1: Single session mode - user already signed in
+ ctx => {
+ if (ctx.clerk.isSignedIn && ctx.environment.authConfig.singleSessionMode) {
+ let destination = ctx.afterSignInUrl || ctx.clerk.buildAfterSignInUrl();
+
+ if (ctx.afterSignInUrl && !isValidRedirectUrl(ctx.afterSignInUrl)) {
+ destination = ctx.clerk.buildAfterSignInUrl();
+ }
+
+ return [
+ {
+ destination,
+ reason: 'User already signed in (single session mode)',
+ },
+ false,
+ ];
+ }
+ return [null, false];
+ },
+
+ // Rule 2: Skip redirect if adding account (preserves add account flow)
+ ctx => {
+ const isAddingAccount = ctx.queryParams?.['__clerk_add_account'];
+ if (isAddingAccount) {
+ return [
+ {
+ destination: ctx.currentPath,
+ reason: 'User is adding account',
+ skipNavigation: true,
+ cleanupQueryParams: ['__clerk_add_account'],
+ },
+ false,
+ ];
+ }
+ return [null, false];
+ },
+
+ // Rule 3: Multi-session mode - redirect to account switcher with active sessions
+ ctx => {
+ if (ctx.hasInitializedRef?.current) {
+ return [null, false];
+ }
+
+ const isMultiSessionMode = !ctx.environment.authConfig.singleSessionMode;
+ const hasActiveSessions = (ctx.clerk.client?.signedInSessions?.length ?? 0) > 0;
+
+ if (hasActiveSessions && isMultiSessionMode) {
+ return [
+ {
+ destination: 'choose',
+ reason: 'Active sessions detected (multi-session mode)',
+ },
+ false,
+ ];
+ }
+ return [null, false];
+ },
+];
+
+/**
+ * Redirect rules for SignUp component
+ */
+export const signUpRedirectRules: RedirectRule[] = [
+ // Rule 1: Single session mode - user already signed in
+ ctx => {
+ if (ctx.clerk.isSignedIn && ctx.environment.authConfig.singleSessionMode) {
+ let destination = ctx.afterSignUpUrl || ctx.clerk.buildAfterSignUpUrl();
+
+ if (ctx.afterSignUpUrl && !isValidRedirectUrl(ctx.afterSignUpUrl)) {
+ destination = ctx.clerk.buildAfterSignUpUrl();
+ }
+
+ return [
+ {
+ destination,
+ reason: 'User already signed in (single session mode)',
+ },
+ false,
+ ];
+ }
+ return [null, false];
+ },
+];