# From setActive to finalize: Migrating Custom Auth Flows to Clerk Core 3

Clerk Core 3 changes how custom authentication flows handle session activation. The `setActive()` method is not removed — it still exists for switching sessions and organizations. What changed: authentication flows (sign-in and sign-up) now use `finalize()` instead of `setActive()`, and the `beforeEmit` callback is replaced by `navigate` everywhere.

This guide walks through every migration scenario with before/after code comparisons. Three rules cover the entire migration:

1. **Use `finalize()`** when a new sign-in or sign-up flow creates the session.
2. **Keep `setActive()`** for switching between existing sessions or changing the active organization.
3. **Replace `beforeEmit` with `navigate`** wherever `setActive()` remains.

| If your code does this...           | Use this in Core 3                        |
| ----------------------------------- | ----------------------------------------- |
| Completes a sign-in or sign-up flow | `signIn.finalize()` / `signUp.finalize()` |
| Switches between existing sessions  | `setActive({ session, navigate })`        |
| Changes the active organization     | `setActive({ organization, navigate })`   |
| Navigates after session change      | `navigate` callback with `decorateUrl`    |

This article covers custom flow migration only. If you use Clerk's prebuilt components (`<SignIn />`, `<SignUp />`), the upgrade CLI handles the mechanical renames automatically. You should still verify your configuration after running it.

## What setActive does in Clerk

In Clerk's session management model, `setActive()` serves three purposes:

1. **Setting the active session** after sign-in or sign-up
2. **Switching between sessions** in multi-session applications
3. **Changing the active organization** in multi-org apps

Core 3 introduces `decorateUrl` inside the `navigate` callback. It transforms a path into the correct URL for the current environment. The developer pattern is: call `decorateUrl('/path')`, check if the result starts with `http` — if it does, use `window.location.href` for a full-page navigation; otherwise, use your framework's client-side router.

### Core 2 setActive pattern

In Core 2, `setActive()` accepted a `beforeEmit` callback — a void side-effect with no access to session data or URL utilities:

```tsx
// Core 2 pattern
await setActive({
  session: signIn.createdSessionId,
  beforeEmit: () => {
    router.push('/dashboard')
  },
})
```

Developers commonly used `beforeEmit` to trigger navigation during session activation. The callback ran as part of the activation process but had no access to the session object or environment-aware URL helpers.

## What changed in Core 3

Three changes affect every custom authentication flow:

1. `beforeEmit` is replaced by `navigate`
2. A new `finalize()` method handles session activation for sign-in and sign-up flows
3. The `decorateUrl` utility provides environment-aware URL transformation

### beforeEmit is now navigate

The `navigate` callback receives `{ session, decorateUrl }` instead of running as a void function. Per the Clerk docs, `navigate` is called just before the session and/or organization is set — giving you a window to trigger navigation before the new auth state propagates to client-side observers like `useUser()`.

**Before (Core 2):**

```tsx
await setActive({
  session: id,
  beforeEmit: () => {
    router.push('/dashboard')
  },
})
```

**After (Core 3):**

```tsx
await setActive({
  session: id,
  navigate: async ({ session, decorateUrl }) => {
    const url = decorateUrl('/dashboard')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      router.push(url)
    }
  },
})
```

### The decorateUrl utility

`decorateUrl` transforms a path into the correct URL for the current environment. It may return the original path unchanged, or it may return an absolute URL when additional processing is required. It is safe to always call — it only modifies the URL when necessary.

The developer pattern is straightforward: always call `decorateUrl(path)`, then check whether the result starts with `http`. If it does, use `window.location.href` for the redirect. Otherwise, use your framework's client-side router (e.g., `router.push()`).

Clerk logs a development warning if `decorateUrl` is not called when needed.

### finalize() for authentication flows

`finalize()` is a new method on the `signIn` and `signUp` objects. It replaces the Core 2 pattern of calling `setActive({ session: signIn.createdSessionId })` after a successful authentication flow.

```tsx
// Core 2: extract session ID manually
await setActive({ session: signIn.createdSessionId })

// Core 3: finalize handles it
await signIn.finalize({
  navigate: async ({ session, decorateUrl }) => {
    const url = decorateUrl('/dashboard')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      router.push(url)
    }
  },
})
```

No need to extract `createdSessionId` — `finalize()` knows which session to activate.

**When to use each:**

- **`finalize()`** — after completing a sign-in or sign-up flow
- **`setActive()`** — for switching between existing sessions or changing the active organization

### New hook shapes

The `useSignIn()` and `useSignUp()` hooks return completely different shapes in Core 3:

| Core 2                            | Core 3                            |
| --------------------------------- | --------------------------------- |
| `{ isLoaded, signIn, setActive }` | `{ signIn, errors, fetchStatus }` |
| `{ isLoaded, signUp, setActive }` | `{ signUp, errors, fetchStatus }` |

- **No more `isLoaded` guard** — the hook manages loading state internally via `fetchStatus`
- **`errors` provides structured field-level errors** — access them via `errors.fields.identifier`, `errors.fields.password`, etc.
- **`setActive` is no longer on the hook** — for session/org switching, get it from `useClerk()` or `useOrganizationList()`
- **The underlying resources changed** — `SignInResource` became `SignInFutureResource`, `SignUpResource` became `SignUpFutureResource`

| Core 2 Method                                            | Core 3 Method                                   |
| -------------------------------------------------------- | ----------------------------------------------- |
| `signIn.create({ identifier, password })`                | `signIn.password({ emailAddress, password })`   |
| `signIn.prepareFirstFactor()` + `attemptFirstFactor()`   | `signIn.emailCode.sendCode()` + `verifyCode()`  |
| `signIn.prepareSecondFactor()` + `attemptSecondFactor()` | `signIn.mfa.verifyTOTP()` / `verifyPhoneCode()` |
| `signIn.authenticateWithRedirect()`                      | `signIn.sso()`                                  |
| `signUp.create({ emailAddress, password })`              | `signUp.password({ emailAddress, password })`   |
| `signUp.prepareEmailAddressVerification()`               | `signUp.verifications.sendEmailCode()`          |
| `signUp.attemptEmailAddressVerification()`               | `signUp.verifications.verifyEmailCode()`        |

## Using the Clerk upgrade CLI

Before making manual changes, run the upgrade CLI. It performs a suite of AST-level codemods that handle mechanical renames automatically:

```bash
npx @clerk/upgrade
```

Or with your preferred package manager:

```bash
pnpm dlx @clerk/upgrade
yarn dlx @clerk/upgrade
bunx @clerk/upgrade
```

### What the CLI automates

- **Package renames**: `@clerk/clerk-react` to `@clerk/react`
- **`beforeEmit` to `navigate`** property rename
- **Component consolidation**: `<SignedIn>`, `<SignedOut>`, `<Protect>` to `<Show>`
- **`appearance.layout`** to `appearance.options`
- **Hook imports**: moves `useSignIn`/`useSignUp` to `/legacy` subpath

Use `--dry-run` to preview changes without writing files. Other flags include `--dir` to target a specific directory and `--glob` to narrow file selection.

### What needs manual work

The CLI handles the rename, but not the rewrite. After running it, you still need to:

- Rewrite `navigate` callback bodies to use `{ session, decorateUrl }`
- Migrate from `setActive()` to `finalize()` for auth completion
- Add `needs_client_trust` status handling in sign-in flows
- Configure `taskUrls` on `<ClerkProvider>` for session tasks
- Migrate from legacy hook API to Core 3 hook API

## Migrating custom sign-in flows

This is the most common migration. The examples below use Next.js with `useRouter()` from `next/navigation`. The Clerk API is identical across React, Next.js, TanStack Start, and other frameworks — only the router import differs.

### Core 2: Custom sign-in with setActive

```tsx
'use client'

import { useState } from 'react'
import { useSignIn } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignInPage() {
  const { isLoaded, signIn, setActive } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const router = useRouter()

  if (!isLoaded) return null

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')

    try {
      const result = await signIn.create({
        identifier: email,
        password,
      })

      if (result.status === 'complete') {
        await setActive({
          session: result.createdSessionId,
          beforeEmit: () => {
            router.push('/dashboard')
          },
        })
      } else if (result.status === 'needs_second_factor') {
        // Handle MFA
      } else {
        console.error('Unexpected status:', result.status)
      }
    } catch (err: any) {
      setError(err.errors?.[0]?.message || 'Sign-in failed')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      {error && <p>{error}</p>}
      <button type="submit">Sign in</button>
    </form>
  )
}
```

### Core 3: Custom sign-in with finalize

```tsx
'use client'

import { useSignIn } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignInPage() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const emailAddress = formData.get('email') as string
    const password = formData.get('password') as string

    const { error } = await signIn.password({
      emailAddress,
      password,
    })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
      return
    }

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: async ({ decorateUrl }) => {
          const url = decorateUrl('/dashboard')
          if (url.startsWith('http')) {
            window.location.href = url
          } else {
            router.push(url)
          }
        },
      })
    } else if (signIn.status === 'needs_second_factor') {
      // Handle MFA — see signIn.mfa.verifyTOTP() or signIn.mfa.verifyPhoneCode()
    } else if (signIn.status === 'needs_client_trust') {
      // Handle Client Trust verification — see the "Error handling" section
    } else {
      console.error('Sign-in attempt not complete:', signIn.status)
    }
  }

  return (
    <form action={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" />
        {errors?.fields?.identifier && <p>{errors.fields.identifier.message}</p>}
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
        {errors?.fields?.password && <p>{errors.fields.password.message}</p>}
      </div>
      <button type="submit" disabled={fetchStatus === 'fetching'}>
        Sign in
      </button>
    </form>
  )
}
```

### Key differences

The Core 3 sign-in migration changes several patterns:

- **No `isLoaded` guard** — `fetchStatus` replaces it. Use `fetchStatus === 'fetching'` to disable buttons during API calls.
- **No `createdSessionId` extraction** — `finalize()` handles session activation internally.
- **`signIn.password()` replaces `signIn.create()`** — methods are now action-specific instead of generic.
- **Error handling shifted from try/catch to return values** — `signIn.password()` returns `{ error }` for programmatic logic. The `errors` object from the hook provides field-level errors for rendering.
- **`needs_client_trust` is new** — this status appears when [Client Trust](https://clerk.com/docs/guides/secure/client-trust.md) is enabled and the user signs in with a password from an unfamiliar device. Per current docs, it only applies to password-based auth — passwordless methods are unaffected. If the user has already enabled MFA, their existing MFA method takes precedence and returns `needs_second_factor` instead. When triggered, verify the user via `signIn.mfa.sendEmailCode()` and `signIn.mfa.verifyEmailCode({ code })`. Check the [Client Trust docs](https://clerk.com/docs/guides/secure/client-trust.md) for the latest details on this flow.
- **Session tasks** — if your app uses session tasks, configure `taskUrls` on [`<ClerkProvider>`](https://clerk.com/docs/nextjs/reference/components/clerk-provider.md) for automatic routing. See the [session tasks section](#handling-session-tasks-after-authentication) for details.

## Migrating custom sign-up flows

The sign-up migration follows the same pattern. Method names changed, and `finalize()` replaces `setActive()`.

### Core 2: Custom sign-up with setActive

```tsx
'use client'

import { useState } from 'react'
import { useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignUpPage() {
  const { isLoaded, signUp, setActive } = useSignUp()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)
  const router = useRouter()

  if (!isLoaded) return null

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    try {
      await signUp.create({ emailAddress: email, password })
      await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
      setPendingVerification(true)
    } catch (err: any) {
      console.error(err.errors?.[0]?.message)
    }
  }

  const handleVerify = async (e: React.FormEvent) => {
    e.preventDefault()

    try {
      const result = await signUp.attemptEmailAddressVerification({ code })
      if (result.status === 'complete') {
        await setActive({ session: signUp.createdSessionId })
        router.push('/dashboard')
      }
    } catch (err: any) {
      console.error(err.errors?.[0]?.message)
    }
  }

  if (pendingVerification) {
    return (
      <form onSubmit={handleVerify}>
        <label htmlFor="code">Verification code</label>
        <input id="code" value={code} onChange={(e) => setCode(e.target.value)} />
        <button type="submit">Verify</button>
      </form>
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">Sign up</button>
    </form>
  )
}
```

### Core 3: Custom sign-up with finalize

```tsx
'use client'

import { useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignUpPage() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const emailAddress = formData.get('email') as string
    const password = formData.get('password') as string

    const { error } = await signUp.password({ emailAddress, password })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
      return
    }

    await signUp.verifications.sendEmailCode()
  }

  const handleVerify = async (formData: FormData) => {
    const code = formData.get('code') as string

    const { error } = await signUp.verifications.verifyEmailCode({ code })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
      return
    }

    if (signUp.status === 'complete') {
      await signUp.finalize({
        navigate: async ({ decorateUrl }) => {
          const url = decorateUrl('/dashboard')
          if (url.startsWith('http')) {
            window.location.href = url
          } else {
            router.push(url)
          }
        },
      })
    }
  }

  if (
    signUp.status === 'missing_requirements' &&
    signUp.unverifiedFields.includes('email_address') &&
    signUp.missingFields.length === 0
  ) {
    return (
      <form action={handleVerify}>
        <div>
          <label htmlFor="code">Verification code</label>
          <input id="code" name="code" type="text" />
          {errors?.fields?.code && <p>{errors.fields.code.message}</p>}
        </div>
        <button type="submit" disabled={fetchStatus === 'fetching'}>
          Verify
        </button>
      </form>
    )
  }

  return (
    <>
      <form action={handleSubmit}>
        <div>
          <label htmlFor="email">Email</label>
          <input id="email" name="email" type="email" />
          {errors?.fields?.emailAddress && <p>{errors.fields.emailAddress.message}</p>}
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input id="password" name="password" type="password" />
          {errors?.fields?.password && <p>{errors.fields.password.message}</p>}
        </div>
        <button type="submit" disabled={fetchStatus === 'fetching'}>
          Sign up
        </button>
      </form>
      <div id="clerk-captcha" />
    </>
  )
}
```

> The `<div id="clerk-captcha" />` element is required in Core 3 sign-up forms. Without it, sign-up requests will fail. Place it in your JSX where you want the [CAPTCHA](https://clerk.com/glossary/captcha.md) challenge to render.

Core 3 also introduces a `signUpIfMissing` option on `signIn.create()` for combined sign-in/sign-up flows that prevent account enumeration. See the [sign-in-or-up custom flow docs](https://clerk.com/docs/guides/development/custom-flows/authentication/sign-in-or-up.md) for details.

## Migrating OAuth and SSO flows

OAuth callback pages changed substantially in Core 3. The `<AuthenticateWithRedirectCallback />` component is gone — you now build the callback page manually with `finalize()`.

> Before using the OAuth examples below, ensure you have configured your social connection(s) in the [Clerk Dashboard](https://dashboard.clerk.com) and set the `NEXT_PUBLIC_CLERK_SIGN_IN_URL` environment variable in your `.env` file (e.g., `NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in`). Without this variable, your app may default to the Account Portal sign-in page instead of your custom flow. See the [OAuth connections custom flow docs](https://clerk.com/docs/guides/development/custom-flows/authentication/oauth-connections.md) for full setup details.

### Core 2: OAuth with AuthenticateWithRedirectCallback

In Core 2, OAuth had two parts — an initiation page and a callback component:

**Initiation:**

```tsx
'use client'

import { useSignIn } from '@clerk/nextjs'

export default function OAuthSignIn() {
  const { signIn } = useSignIn()

  const signInWithGoogle = () => {
    signIn.authenticateWithRedirect({
      strategy: 'oauth_google',
      redirectUrl: '/sso-callback',
      redirectUrlComplete: '/dashboard',
    })
  }

  return <button onClick={signInWithGoogle}>Sign in with Google</button>
}
```

**Callback page:**

```tsx
import { AuthenticateWithRedirectCallback } from '@clerk/nextjs'

export default function SSOCallback() {
  return <AuthenticateWithRedirectCallback />
}
```

### Core 3: OAuth with sso() and finalize

Core 3 replaces `authenticateWithRedirect()` with `signIn.sso()` and requires a manual callback page:

**Initiation:**

```tsx
'use client'

import { OAuthStrategy } from '@clerk/shared/types'
import { useSignIn } from '@clerk/nextjs'

export default function OAuthSignIn() {
  const { signIn, errors } = useSignIn()

  const signInWith = async (strategy: OAuthStrategy) => {
    const { error } = await signIn.sso({
      strategy,
      redirectCallbackUrl: '/sso-callback',
      redirectUrl: '/sign-in/tasks',
    })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
    }
  }

  return (
    <>
      <button onClick={() => signInWith('oauth_google')}>Sign in with Google</button>
      {errors && <p>{JSON.stringify(errors, null, 2)}</p>}
    </>
  )
}
```

Note the parameter rename: `redirectUrl` + `redirectUrlComplete` became `redirectCallbackUrl` + `redirectUrl`.

**Callback page:**

```tsx
'use client'

import { useClerk, useSignIn, useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'

export default function SSOCallback() {
  const clerk = useClerk()
  const { signIn } = useSignIn()
  const { signUp } = useSignUp()
  const router = useRouter()
  const hasRun = useRef(false)

  const handleNavigate = async ({
    session,
    decorateUrl,
  }: {
    session: any
    decorateUrl: (url: string) => string
  }) => {
    if (session?.currentTask) {
      console.log(session?.currentTask)
      return
    }
    const url = decorateUrl('/')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      router.push(url)
    }
  }

  useEffect(() => {
    ;(async () => {
      if (!clerk.loaded || hasRun.current) return
      hasRun.current = true

      // Happy path: sign-in completed by the OAuth provider
      if (signIn.status === 'complete') {
        await signIn.finalize({ navigate: handleNavigate })
        return
      }

      // Transfer: OAuth returned a sign-up, but user has an existing account
      if (signUp.isTransferable) {
        await signIn.create({ transfer: true })
        if (signIn.status === 'complete') {
          await signIn.finalize({ navigate: handleNavigate })
          return
        }
      }

      // Additional transfer scenarios exist — see Clerk's OAuth custom flows docs
      // for handling signIn.isTransferable, existingSession, and other edge cases

      router.push('/sign-in')
    })()
  }, [clerk, signIn, signUp])

  return (
    <div>
      <div id="clerk-captcha" />
    </div>
  )
}
```

The callback page handles multiple scenarios. The example above shows the happy path and one transfer case. See the [OAuth connections custom flow docs](https://clerk.com/docs/guides/development/custom-flows/authentication/oauth-connections.md) for the full transfer matrix including `signIn.isTransferable`, `existingSession`, and `needs_second_factor` handling.

### Enterprise SSO

Enterprise [SSO](https://clerk.com/glossary/single-sign-on-sso.md) uses the same `signIn.sso()` method with a strategy rename:

```tsx
// Core 2
signIn.authenticateWithRedirect({
  strategy: 'saml',
  // ...
})

// Core 3
signIn.sso({
  strategy: 'enterprise_sso',
  identifier: email, // Email domain determines the enterprise connection
  redirectCallbackUrl: '/sso-callback',
  redirectUrl: '/sign-in/tasks',
})
```

The related property rename is `user.samlAccounts` to `user.enterpriseAccounts`.

## Migrating multi-session applications

For session switching, `setActive()` remains the correct method — not `finalize()`. The key change is `client.activeSessions` renamed to `client.sessions`, and `beforeEmit` replaced by `navigate`.

```tsx
'use client'

import { useClerk } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SessionSwitcher() {
  const { client, setActive, signOut, session: currentSession } = useClerk()
  const router = useRouter()

  const switchSession = async (sessionId: string) => {
    await setActive({
      session: sessionId,
      navigate: async ({ decorateUrl }) => {
        const url = decorateUrl('/')
        if (url.startsWith('http')) {
          window.location.href = url
        } else {
          router.push(url)
        }
      },
    })
  }

  return (
    <div>
      <h2>Active sessions</h2>
      <ul>
        {client.sessions.map((session) => (
          <li key={session.id}>
            {session.user?.primaryEmailAddress?.emailAddress}
            {session.id === currentSession?.id ? (
              <span> (current)</span>
            ) : (
              <button onClick={() => switchSession(session.id)}>Switch</button>
            )}
          </li>
        ))}
      </ul>
      <button onClick={() => signOut(currentSession?.id)}>Sign out current</button>
      <button onClick={() => signOut()}>Sign out all</button>
    </div>
  )
}
```

Key changes from Core 2:

- `client.activeSessions` is now `client.sessions`
- `beforeEmit` is now `navigate` with `{ session, decorateUrl }`
- `setActive()` comes from `useClerk()`, not from `useSignIn()`

## Migrating organization switching

Organization switching uses `setActive({ organization })` — the same pattern as Core 2, but with the `navigate` callback replacing `beforeEmit`.

### Prebuilt component prop rename

If you use the `<OrganizationSwitcher>` component, one prop was renamed:

**Before (Core 2):**

```tsx
<OrganizationSwitcher afterSwitchOrganizationUrl="/dashboard" />
```

**After (Core 3):**

```tsx
<OrganizationSwitcher afterSelectOrganizationUrl="/dashboard" />
```

### Custom organization switcher

For custom switchers using `useOrganizationList()`:

```tsx
'use client'

import { useAuth, useOrganizationList } from '@clerk/nextjs'

export default function OrgSwitcher() {
  const { isLoaded, setActive, userMemberships } = useOrganizationList({
    userMemberships: { pageSize: 5, keepPreviousData: true },
  })
  const { orgId } = useAuth()

  if (!isLoaded) return <p>Loading...</p>

  return (
    <div>
      <h2>Organizations</h2>
      <ul>
        {userMemberships?.data?.map((mem) => (
          <li key={mem.id}>
            {mem.organization.name} — {mem.role}
            {orgId !== mem.organization.id && (
              <button onClick={() => setActive({ organization: mem.organization.id })}>
                Switch
              </button>
            )}
          </li>
        ))}
      </ul>
      <button
        disabled={!userMemberships?.hasPreviousPage}
        onClick={() => userMemberships?.fetchPrevious?.()}
      >
        Previous
      </button>
      <button
        disabled={!userMemberships?.hasNextPage}
        onClick={() => userMemberships?.fetchNext?.()}
      >
        Next
      </button>
    </div>
  )
}
```

The `navigate` callback is optional for organization switching when you don't need to redirect after the switch. When provided, it follows the same `{ session, decorateUrl }` pattern.

## Handling session tasks after authentication

Session tasks are a Core 3 concept — pending requirements users must complete after authentication (e.g., `choose-organization`, `reset-password`, `setup-mfa`). Sessions with pending tasks enter a `pending` state and are treated as signed-out by default.

### Recommended: Configure taskUrls

The simplest approach is configuring `taskUrls` on `<ClerkProvider>`. Clerk handles routing automatically — no manual `navigate` logic needed:

```tsx
<ClerkProvider
  taskUrls={{
    'choose-organization': '/tasks/choose-organization',
    'reset-password': '/tasks/reset-password',
    'setup-mfa': '/tasks/setup-mfa',
  }}
>
  {children}
</ClerkProvider>
```

Clerk provides prebuilt components for each task type: `<TaskSetupMFA />`, `<TaskResetPassword />`, and `<TaskChooseOrganization />`. Mount them at the corresponding routes.

When `taskUrls` is configured, it overrides the `navigate` callback behavior — Clerk redirects to the task page automatically. This is the recommended approach for most applications.

### When manual handling is needed

Only when building fully custom task UIs do you need to check `session?.currentTask` in the `navigate` callback and route based on `session.currentTask.key`. See the [session tasks custom flow docs](https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks.md) for the full implementation pattern.

## Error handling and best practices

### New error pattern

Core 3 authentication methods return `{ error: ClerkError | null }` instead of throwing. Use the `errors` object from hooks for UI rendering and `error` from methods for programmatic logic:

```tsx
const { signIn, errors, fetchStatus } = useSignIn()

const handleSubmit = async (formData: FormData) => {
  const { error } = await signIn.password({
    emailAddress: formData.get('email') as string,
    password: formData.get('password') as string,
  })

  // Programmatic: check the method's return value
  if (error) {
    console.error(error.code, error.message)
    return
  }
}

// UI: render field-level errors from the hook
{
  errors?.fields?.identifier && <p>{errors.fields.identifier.message}</p>
}
{
  errors?.fields?.password && <p>{errors.fields.password.message}</p>
}
```

### Handle needs\_client\_trust

When [Client Trust](https://clerk.com/docs/guides/secure/client-trust.md) is enabled and a user signs in from an unfamiliar device, `signIn.status` returns `needs_client_trust` instead of `complete`. Per the current Client Trust docs, this only applies to password-based authentication — passwordless methods (email links, OTPs, passkeys, OAuth) are unaffected. If the user has already enabled MFA, their existing MFA method takes precedence and the status will be `needs_second_factor` instead. Check the [Client Trust reference](https://clerk.com/docs/guides/secure/client-trust.md) for the latest behavior details. Handle `needs_client_trust` by sending a verification code:

```tsx
if (signIn.status === 'needs_client_trust') {
  // Find the email code factor
  const emailCodeFactor = signIn.supportedSecondFactors?.find(
    (factor) => factor.strategy === 'email_code',
  )
  if (emailCodeFactor) {
    await signIn.mfa.sendEmailCode()
    // Show verification code input
  }
}
```

After the user enters the code:

```tsx
await signIn.mfa.verifyEmailCode({ code })

if (signIn.status === 'complete') {
  await signIn.finalize({
    navigate: async ({ decorateUrl }) => {
      const url = decorateUrl('/dashboard')
      if (url.startsWith('http')) {
        window.location.href = url
      } else {
        router.push(url)
      }
    },
  })
}
```

### Always use decorateUrl

Always wrap destination URLs with `decorateUrl` in the `navigate` callback. Clerk logs a development warning if it's not called when needed. Handle both return types:

- **Absolute URL** (starts with `http`) — use `window.location.href`
- **Relative path** — use your framework's client-side router

### Test in development first

Clerk's development instances support the same custom-flow APIs as production. Test your migrated flows in a development instance before deploying to catch configuration issues early.

## Migration checklist

1. Run `npx @clerk/upgrade` (or pnpm/yarn/bun equivalent)
2. Verify `beforeEmit` to `navigate` renames applied by the CLI
3. Identify `setActive()` calls that finish auth flows (sign-in/sign-up completion)
4. Convert those calls to `signIn.finalize()` / `signUp.finalize()`
5. Keep `setActive()` for session switching and organization switching
6. Rewrite `navigate` callback bodies to use `{ session, decorateUrl }`
7. Add `needs_client_trust` status handling in sign-in flows
8. Configure `taskUrls` on `<ClerkProvider>` for session tasks
9. Test in Clerk's development environment before deploying

## Quick reference: Core 2 vs. Core 3

### API changes

| Core 2                               | Core 3                            |
| ------------------------------------ | --------------------------------- |
| `beforeEmit`                         | `navigate`                        |
| `setActive()` after auth             | `finalize()`                      |
| `client.activeSessions`              | `client.sessions`                 |
| `afterSwitchOrganizationUrl`         | `afterSelectOrganizationUrl`      |
| `strategy: 'saml'`                   | `strategy: 'enterprise_sso'`      |
| `user.samlAccounts`                  | `user.enterpriseAccounts`         |
| `authenticateWithRedirect()`         | `sso()`                           |
| `<AuthenticateWithRedirectCallback>` | Manual callback with `finalize()` |

### Method mapping

| Core 2                                                   | Core 3                                          |
| -------------------------------------------------------- | ----------------------------------------------- |
| `signIn.create({ identifier, password })`                | `signIn.password({ emailAddress, password })`   |
| `signIn.prepareFirstFactor()` + `attemptFirstFactor()`   | `signIn.emailCode.sendCode()` + `verifyCode()`  |
| `signIn.prepareSecondFactor()` + `attemptSecondFactor()` | `signIn.mfa.verifyTOTP()` / `verifyPhoneCode()` |
| `signIn.authenticateWithRedirect()`                      | `signIn.sso()`                                  |
| `signUp.create({ emailAddress, password })`              | `signUp.password({ emailAddress, password })`   |
| `signUp.prepareEmailAddressVerification()`               | `signUp.verifications.sendEmailCode()`          |
| `signUp.attemptEmailAddressVerification()`               | `signUp.verifications.verifyEmailCode()`        |

> **Other Core 3 changes to verify**: `@clerk/clerk-react` renamed to `@clerk/react`. `<SignedIn>`/`<SignedOut>`/`<Protect>` consolidated into `<Show>`. `appearance.layout` renamed to `appearance.options`. `getToken()` now throws `ClerkOfflineError` when offline — wrap in try/catch, use `ClerkOfflineError.is(error)` from `@clerk/react/errors`. The [Core 3 Upgrade Guide](https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3.md) lists current runtime minimums (Node.js, Next.js, Expo, TanStack Start), and the [custom-flow docs](https://clerk.com/docs/guides/development/custom-flows/authentication/email-password.md) list SDK minimums (`@clerk/react`, `@clerk/nextjs`, etc.). Check those pages for the most current version requirements.

## FAQ

## FAQ

### Is setActive completely removed in Core 3?

No. `setActive()` still exists for session switching and organization switching. The change is that `beforeEmit` is replaced by `navigate`, and authentication flows now use `finalize()` instead of `setActive()`.

### What is the difference between finalize() and setActive()?

`finalize()` is a method on `signIn` and `signUp` objects — it activates the session created by an authentication flow. `setActive()` is on the Clerk object — it switches between existing sessions or organizations.

### Do I have to use decorateUrl in the navigate callback?

Yes. `decorateUrl` transforms a path into the correct URL for the current environment. Skipping it may cause navigation to break in certain deployment configurations. Clerk logs a development warning if it is not called when needed.

### Can I use the Clerk upgrade CLI to automate this migration?

Partially. The CLI renames `beforeEmit` to `navigate` and moves hook imports to `/legacy` subpaths. But `navigate` callback bodies, `finalize()` adoption, and `needs_client_trust` handling require manual work.

### What happens if I do not handle session tasks?

Users with pending tasks (MFA setup, org selection, password reset) may be stuck in a `pending` state and treated as signed-out. Configure `taskUrls` on `<ClerkProvider>` — Clerk handles routing automatically. Manual `session?.currentTask` handling is only needed for custom task UIs.

### Does this migration differ between React, Next.js, and TanStack Start?

The Core 3 API changes (`navigate`, `finalize`, `decorateUrl`) are consistent across all frameworks. The only framework-specific code is the router used for client-side navigation (e.g., Next.js `useRouter` vs. TanStack Router).

### What is the needs\_client\_trust status?

`needs_client_trust` is a sign-in status in Core 3 that appears when Client Trust is enabled and the user is on an unfamiliar device. It only applies to password-based authentication. If the user has MFA enabled, MFA takes precedence and returns `needs_second_factor` instead. Handle `needs_client_trust` by sending a verification code via `signIn.mfa.sendEmailCode()`.

### How does error handling change in Core 3 custom flows?

Core 3 authentication methods return `{ error: ClerkError | null }` instead of throwing. Destructure `error` from method calls for programmatic logic and use the `errors` object from hooks for UI rendering with field-level access via `errors.fields`. For `getToken()` specifically, it now throws `ClerkOfflineError` when offline.

### What minimum versions are required for Core 3?

The [Core 3 Upgrade Guide](https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3.md) lists runtime minimums and the [custom-flow docs](https://clerk.com/docs/guides/development/custom-flows/authentication/email-password.md) list SDK minimums. Check those pages for the most current version requirements, as specific numbers may change between releases.

### Can I migrate incrementally or do I need to update everything at once?

The upgrade CLI moves `useSignIn`/`useSignUp` to `/legacy` subpaths so old code keeps working with Core 3 packages. However, the recommended path is to complete the full migration to Core 3 hooks. `/legacy` is a transitional bridge, not an endorsed endpoint. If you need more time, Per the [Clerk versioning policy](https://clerk.com/docs/guides/development/upgrading/versioning.md), Core 2 LTS receives critical security patches through January 2027.
