Skip to main content
Articles

Migrating from @clerk/clerk-expo to @clerk/expo — Breaking Changes, Native Components, and the Complete Upgrade Path

Author: Roy Anger
Published: (last updated )

This is Part 1 of the migration guide for @clerk/clerk-expo to @clerk/expo (Core 3). This part covers the prerequisites, using the automated upgrade CLI, package renames, ClerkProvider configuration, the new Show component, and updating your hook APIs and appearance settings. For adopting native components and passkeys, see Part 2.

The @clerk/clerk-expo package is deprecated. Its replacement, @clerk/expo, ships with Clerk Core 3: native components powered by SwiftUI and Jetpack Compose, platform-native OAuth, passkey support, offline resilience, and a redesigned authentication hook API. Core 3 also adds proactive background token refresh, so getToken() no longer blocks API calls while a session token renews (Core 3 Changelog, 2026-03-03). Run the Clerk Upgrade CLI to automate most import path changes, then follow this guide for the remaining breaking changes — including the new Show component, redesigned hooks, and native component adoption.

Core 2 is in long-term support until January 2027 (Versioning docs). You're not forced to migrate today, but @clerk/clerk-expo won't receive new features, and the native components plus offline resilience make this upgrade worth prioritizing.

Prerequisites and Compatibility Requirements

Minimum Version Requirements

DependencyMinimum VersionNotes
Expo SDK53Peer dep >=53 <56
React Native0.73.0
React18.0.0 or 19.0.0Peer dep ^18.0.0 || ^19.0.0
Node.js20.9.0
@clerk/expo3.0.0Latest: 3.1.6 (April 2026)
iOS (passkeys only)16.0Set manually via expo-build-properties

If you're on an older Expo SDK, upgrade first. Follow the Expo SDK upgrade walkthrough to reach SDK 53+.

Three Authentication Approaches

@clerk/expo supports 3 approaches. Choose based on your requirements:

ApproachAuth UIOAuthRequires Dev BuildBest For
JavaScript onlyCustom React Native flowsBrowser-based (useSSO)No (works in Expo Go)Full UI control
JS + Native Sign-inCustom flows + native OAuth buttonsNative (no browser)YesCustom UI with native Google/Apple
Native Components (beta)Pre-built native UI (AuthView)Native (no browser)YesFastest integration

Development Build Requirement

Native features (AuthView, UserButton, native OAuth, passkeys) require a development build. Expo Go can't load custom native code.

Create a development build:

npx expo run:ios

Or for Android:

npx expo run:android

For CI/CD, use EAS Build.

Clerk Dashboard Configuration

Before migrating, configure your Clerk Dashboard:

  1. Enable Native API on the Native Applications page (deployment guide)
  2. Register your apps: iOS (Team ID + Bundle ID), Android (package name)
  3. Configure OAuth credentials for Google and Apple sign-in if using native OAuth
  4. Set up domains for passkeys and OAuth redirects

Step 1: Run the Clerk Upgrade CLI

Start with the automated migration tool. It handles the most common changes through AST-level code transforms.

npx @clerk/upgrade

Other package managers:

pnpm dlx @clerk/upgrade
# or
yarn dlx @clerk/upgrade
# or
bunx @clerk/upgrade

The CLI supports --sdk and --dir flags for targeted scanning in monorepos.

What the CLI Handles

  • Package rename: @clerk/clerk-expo to @clerk/expo
  • Import path updates across all files
  • SignedIn, SignedOut, Protect to Show component replacements
  • ClerkProvider positioning
  • Re-exports, aliased imports, and monorepo files

Warning

The CLI does not handle these changes. You'll need to make them manually:

  • app.json plugin configuration
  • Environment variable updates (EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY)
  • Core 3 authentication hook API refactoring (useSignIn/useSignUp hook body changes)
  • Token cache configuration (@clerk/expo/token-cache)
  • Offline error handling (ClerkOfflineError)
  • useOAuth to useSSO migration
  • Native component adoption

Review CLI Output

After running the CLI, review its output for warnings. The tool uses regex-based scanning and may miss unusual import patterns, bound methods, or indirect calls. Verify that custom wrappers or re-exports in your codebase were caught.

Step 2: Package Rename and Import Path Updates

Install the New Package

Remove @clerk/clerk-expo and install its replacement:

npx expo install @clerk/expo expo-secure-store

For native components, add development dependencies:

npx expo install expo-auth-session expo-web-browser expo-dev-client

Import Path Reference Table

Every import from @clerk/clerk-expo changes to @clerk/expo or one of its 14 subpath exports:

FeatureOld ImportNew Import
Core hooks@clerk/clerk-expo@clerk/expo
Control components@clerk/clerk-expo (SignedIn, SignedOut, Protect)@clerk/expo (Show)
Native componentsN/A (new)@clerk/expo/native
Token cacheCustom implementation@clerk/expo/token-cache
Resource cacheN/A (new)@clerk/expo/resource-cache
PasskeysN/A (new)@clerk/expo/passkeys
Error typesN/A (new)@clerk/react/errors
Apple Sign-In@clerk/clerk-expo@clerk/expo/apple
Google Sign-In@clerk/clerk-expo@clerk/expo/google
Web components@clerk/clerk-expo/web@clerk/expo/web
Local credentials@clerk/clerk-expo@clerk/expo/local-credentials
Legacy hooksN/A@clerk/expo/legacy
Types@clerk/types@clerk/shared/types

Before (Core 2):

app/example.tsx
import { useAuth, useUser, SignedIn, SignedOut } from '@clerk/clerk-expo'

After (Core 3, @clerk/expo >=3.0.0):

app/example.tsx
import { useAuth, useUser, Show } from '@clerk/expo'

Removed Exports

  • Clerk export removed. Use useClerk() inside components or getClerkInstance() outside them.
  • @clerk/types deprecated. Types are now exported from SDK packages via @clerk/shared/types.
  • @clerk/expo/secure-store deprecated. Use @clerk/expo/resource-cache instead.

Step 3: ClerkProvider Configuration Changes

publishableKey Is Now Required

This is a breaking change. The publishable key must be passed explicitly to ClerkProvider.

Why? Environment variables inside node_modules aren't inlined during React Native production builds. Without the explicit prop, your app will crash in production. The publishable key encodes the Frontend API URL in base64 (How Clerk Works).

Before (Core 2):

app/_layout.tsx
import { ClerkProvider } from '@clerk/clerk-expo'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider>
      <Slot />
    </ClerkProvider>
  )
}

After (Core 3, @clerk/expo >=3.0.0):

app/_layout.tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

if (!publishableKey) {
  throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
}

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Slot />
    </ClerkProvider>
  )
}

Token Cache with expo-secure-store

Without tokenCache, Clerk stores tokens in memory. They're lost when the app restarts, forcing users to sign in again.

The tokenCache from @clerk/expo/token-cache uses expo-secure-store with AFTER_FIRST_UNLOCK keychain accessibility for encrypted persistent storage.

Install if you haven't already:

npx expo install expo-secure-store

app.json Plugin Configuration

The @clerk/expo config plugin automatically adds the native SDKs (clerk-ios and clerk-android) and configures required build settings.

app.json
{
  "expo": {
    "plugins": [
      "expo-secure-store",
      [
        "@clerk/expo",
        {
          "appleSignIn": true
        }
      ]
    ]
  }
}

Plugin options:

OptionTypeDefaultDescription
appleSignInbooleantrueAdds Apple Sign-In entitlement
keychainServicestringundefinedFor extension targets sharing keychain

The plugin handles these automatically:

  • iOS: Adds clerk-ios via SPM (ClerkKit + ClerkKitUI), injects ClerkViewFactory.swift, modifies AppDelegate.swift
  • Android: Adds META-INF exclusions, Kotlin metadata version flags
  • Google Sign-In: Reads EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME for the iOS URL scheme

Step 4: Control Component Migration: SignedIn, SignedOut, Protect to Show

The <Show> component replaces 3 separate components: <SignedIn>, <SignedOut>, and <Protect>. It handles both authentication state checks and authorization (role-based access control) in a single API.

Note

<Show> only visually hides content. The underlying views remain accessible to inspection. For sensitive data, always perform server-side authorization checks.

Authentication State Checks

Before (Core 2):

app/home.tsx
import { SignedIn, SignedOut } from '@clerk/clerk-expo'
import { Text } from 'react-native'

export default function HomeScreen() {
  return (
    <>
      <SignedIn>
        <Text>Welcome back!</Text>
      </SignedIn>
      <SignedOut>
        <Text>Please sign in.</Text>
      </SignedOut>
    </>
  )
}

After (Core 3, @clerk/expo >=3.0.0):

app/home.tsx
import { Show } from '@clerk/expo'
import { Text } from 'react-native'

export default function HomeScreen() {
  return (
    <>
      <Show when="signed-in">
        <Text>Welcome back!</Text>
      </Show>
      <Show when="signed-out">
        <Text>Please sign in.</Text>
      </Show>
    </>
  )
}

Authorization Checks

<Protect> with role/permission props becomes <Show> with object-based when:

Before (Core 2):

app/admin.tsx
import { Protect } from '@clerk/clerk-expo'
import { Text } from 'react-native'

export default function AdminPanel() {
  return (
    <Protect role="org:admin" fallback={<Text>Not authorized</Text>}>
      <Text>Admin panel content</Text>
    </Protect>
  )
}

After (Core 3, @clerk/expo >=3.0.0):

app/admin.tsx
import { Show } from '@clerk/expo'
import { Text } from 'react-native'

export default function AdminPanel() {
  return (
    <Show when={{ role: 'org:admin' }} fallback={<Text>Not authorized</Text>}>
      <Text>Admin panel content</Text>
    </Show>
  )
}

All Show Component when Patterns

PatternExampleCore 2 Equivalent
Signed inwhen="signed-in"<SignedIn>
Signed outwhen="signed-out"<SignedOut>
Rolewhen={{ role: 'org:admin' }}<Protect role="org:admin">
Permissionwhen={{ permission: 'org:invoices:create' }}<Protect permission="...">
Feature (new)when={{ feature: 'premium_access' }}N/A
Plan (new)when={{ plan: 'bronze' }}N/A
Custom logicwhen={(has) => has({ role: 'org:admin' })}<Protect condition={(has) => ...}>

treatPendingAsSignedOut

The treatPendingAsSignedOut prop (defaults to true) controls how pending sessions are treated. When using native components, set it to false to prevent the pending session state from showing as signed out during native-to-JS session sync.

Two places to set this:

app/native-example.tsx
import { Show, useAuth } from '@clerk/expo'
import { Text } from 'react-native'

// On the Show component
function NativeAwareShow() {
  return (
    <Show treatPendingAsSignedOut={false} when="signed-in">
      <Text>Content</Text>
    </Show>
  )
}

// On the useAuth hook
function NativeAwareHook() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  // ...
}

Step 5: Hook API Changes

This is the largest manual migration step. The @clerk/upgrade CLI doesn't automate these changes because they require understanding your authentication flow logic.

useSignIn: Before and After

Core 3 replaces the imperative signIn.create() + setActive() pattern with method-specific APIs, structured errors, and fetchStatus tracking.

Before (Core 2):

app/(auth)/sign-in.tsx
import { useSignIn } from '@clerk/clerk-expo'
import { useState } from 'react'
import { Text, TextInput, Button, View } from 'react-native'
import { useRouter } from 'expo-router'

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

  const onSignIn = async () => {
    if (!isLoaded) return

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

      if (result.status === 'complete') {
        await setActive({ session: result.createdSessionId })
        router.replace('/(home)')
      }
    } catch (err: any) {
      setError(err.errors?.[0]?.message || 'Sign in failed')
    }
  }

  return (
    <View>
      <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />
      {error ? <Text>{error}</Text> : null}
      <Button title="Sign In" onPress={onSignIn} />
    </View>
  )
}

After (Core 3, @clerk/expo >=3.0.0):

app/(auth)/sign-in.tsx
import { useSignIn } from '@clerk/expo'
import { useState } from 'react'
import { Text, TextInput, Pressable, View } from 'react-native'
import { useRouter, type Href } from 'expo-router'

export default function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [mfaCode, setMfaCode] = useState('')
  const router = useRouter()

  const onSignIn = async () => {
    await signIn.password({ emailAddress: email, password })

    if (signIn.status === 'needs_second_factor') {
      await signIn.mfa.sendEmailCode()
      return
    }

    if (signIn.status === 'needs_client_trust') {
      await signIn.mfa.sendEmailCode()
      return
    }

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          router.push(decorateUrl('/') as Href)
        },
      })
    }
  }

  const onVerifyMfa = async () => {
    await signIn.mfa.verifyEmailCode({ code: mfaCode })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          router.push(decorateUrl('/') as Href)
        },
      })
    }
  }

  return (
    <View>
      <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />

      {errors?.fields?.identifier ? <Text>{errors.fields.identifier.message}</Text> : null}
      {errors?.fields?.password ? <Text>{errors.fields.password.message}</Text> : null}

      {signIn.status === 'needs_second_factor' || signIn.status === 'needs_client_trust' ? (
        <>
          <TextInput value={mfaCode} onChangeText={setMfaCode} placeholder="Verification code" />
          {errors?.fields?.code ? <Text>{errors.fields.code.message}</Text> : null}
          <Pressable onPress={onVerifyMfa} disabled={fetchStatus === 'fetching'}>
            <Text>Verify</Text>
          </Pressable>
        </>
      ) : (
        <Pressable onPress={onSignIn} disabled={fetchStatus === 'fetching'}>
          <Text>Sign In</Text>
        </Pressable>
      )}
    </View>
  )
}

Key changes to notice:

  • Return type: { signIn, errors, fetchStatus } replaces { isLoaded, signIn, setActive }
  • Method-specific calls: signIn.password() replaces signIn.create({ identifier, password })
  • Structured errors: errors.fields.identifier?.message replaces try/catch with err.errors?.[0]?.message
  • fetchStatus: 'idle' or 'fetching', useful for disabling buttons during API calls
  • finalize replaces setActive: signIn.finalize({ navigate }) replaces setActive({ session })
  • needs_client_trust: New status for credential stuffing protection. Triggers on new devices with valid password and no MFA enabled. Auto-enabled for apps created after November 14, 2025 (Client Trust, 2025-11-14). Only affects password-based sign-ins.

useSignUp: Before and After

Before (Core 2):

app/(auth)/sign-up.tsx
import { useSignUp } from '@clerk/clerk-expo'
import { useState } from 'react'
import { Text, TextInput, Button, View } from 'react-native'
import { useRouter } from 'expo-router'

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

  const onSignUp = async () => {
    if (!isLoaded) return

    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 onVerify = async () => {
    try {
      const result = await signUp.attemptEmailAddressVerification({ code })
      if (result.status === 'complete') {
        await setActive({ session: result.createdSessionId })
        router.replace('/(home)')
      }
    } catch (err: any) {
      console.error(err.errors?.[0]?.message)
    }
  }

  return (
    <View>
      {pendingVerification ? (
        <>
          <TextInput value={code} onChangeText={setCode} placeholder="Verification code" />
          <Button title="Verify" onPress={onVerify} />
        </>
      ) : (
        <>
          <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
          <TextInput value={password} onChangeText={setPassword} secureTextEntry />
          <Button title="Sign Up" onPress={onSignUp} />
        </>
      )}
    </View>
  )
}

After (Core 3, @clerk/expo >=3.0.0):

app/(auth)/sign-up.tsx
import { useSignUp } from '@clerk/expo'
import { useState } from 'react'
import { Text, TextInput, Pressable, View } from 'react-native'
import { useRouter, type Href } from 'expo-router'

export default function SignUpScreen() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const router = useRouter()

  const onSignUp = async () => {
    await signUp.password({ emailAddress: email, password })

    if (
      signUp.status === 'missing_requirements' &&
      signUp.unverifiedFields.includes('email_address')
    ) {
      await signUp.verifications.sendEmailCode()
    }
  }

  const onVerify = async () => {
    await signUp.verifications.verifyEmailCode({ code })

    if (signUp.status === 'complete') {
      await signUp.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          router.push(decorateUrl('/') as Href)
        },
      })
    }
  }

  return (
    <View>
      {signUp.status === 'missing_requirements' &&
      signUp.unverifiedFields.includes('email_address') ? (
        <>
          <TextInput value={code} onChangeText={setCode} placeholder="Verification code" />
          {errors?.fields?.code ? <Text>{errors.fields.code.message}</Text> : null}
          <Pressable onPress={onVerify} disabled={fetchStatus === 'fetching'}>
            <Text>Verify Email</Text>
          </Pressable>
        </>
      ) : (
        <>
          <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
          <TextInput value={password} onChangeText={setPassword} secureTextEntry />
          {errors?.fields?.emailAddress ? <Text>{errors.fields.emailAddress.message}</Text> : null}
          {errors?.fields?.password ? <Text>{errors.fields.password.message}</Text> : null}
          <Pressable onPress={onSignUp} disabled={fetchStatus === 'fetching'}>
            <Text>Sign Up</Text>
          </Pressable>
          <View nativeID="clerk-captcha" />
        </>
      )}
    </View>
  )
}

Important

The <View nativeID="clerk-captcha" /> element is required in sign-up forms. It uses nativeID (not id) in React Native. Cloudflare-based bot detection has limitations in non-browser environments, but this element must be present.

setActive Callback Changes

The beforeEmit callback is replaced by navigate. The new callback receives session and decorateUrl:

Before (Core 2):

utils/auth-helpers.tsx
await setActive({
  session: result.createdSessionId,
  beforeEmit: (session) => {
    router.push('/(home)')
  },
})

After (Core 3, @clerk/expo >=3.0.0):

utils/auth-helpers.tsx
await signIn.finalize({
  navigate: ({ session, decorateUrl }) => {
    if (session?.currentTask) return
    router.push(decorateUrl('/') as Href)
  },
})

Always wrap destination URLs with decorateUrl(). Check session?.currentTask before navigating. If a task exists (like an organization invitation), the SDK handles routing.

useAuth, useUser, useClerk, useSession

Import paths changed, but the API is largely the same. One change: useAuth().getToken is now always a function (never undefined). Use try/catch instead of conditional checks.

Before (Core 2):

utils/token-helper.tsx
import { useAuth } from '@clerk/clerk-expo'

async function useApiToken() {
  const { getToken } = useAuth()
  const token = getToken ? await getToken() : null
}

After (Core 3, @clerk/expo >=3.0.0):

utils/token-helper.tsx
import { useAuth } from '@clerk/expo'

async function useApiToken() {
  const { getToken } = useAuth()
  const token = await getToken() // always a function, use try/catch for errors
}

Tip

The signIn and signUp objects from the new hooks are not referentially stable. They change identity as the flow progresses. Always include them in useEffect, useCallback, and useMemo dependency arrays.

Legacy Import Path

For large codebases, @clerk/expo/legacy provides the old Core 2 useSignIn/useSignUp API as a stepping stone. You can rename the package first, then refactor auth flows later.

app/(auth)/sign-in-legacy.tsx
// Core 2 API from the new package. Will be removed in a future release.
import { useSignIn } from '@clerk/expo/legacy'

The legacy API will be removed in a future release. Plan to migrate to the updated authentication hook API.

Step 6: Appearance and Theming Changes

Configuration Restructuring

appearance.layout is renamed to appearance.options:

Before (Core 2):

app/_layout.tsx
<ClerkProvider
  appearance={{
    layout: {
      showOptionalFields: true,
    },
  }}
></ClerkProvider>

After (Core 3, @clerk/expo >=3.0.0):

app/_layout.tsx
<ClerkProvider
  appearance={{
    options: {
      showOptionalFields: false,
    },
  }}
></ClerkProvider>

Other appearance changes:

  • showOptionalFields default changed from true to false. Set it explicitly if you want optional fields visible.
  • colorRing and colorModalBackdrop now render at full opacity. Use rgba() values to restore previous behavior.
  • Experimental prefixes standardized. All experimental_ and experimental__ prefixes are now __experimental_. Update any custom theme configuration.
  • Automatic light/dark theming. Components match your app's color scheme without manual configuration.

Step 7: Deprecation Removals and Renamed APIs

Redirect Prop Changes

Before (Core 2):

app/_layout.tsx
<ClerkProvider afterSignInUrl="/(home)" afterSignUpUrl="/(home)"></ClerkProvider>

After (Core 3, @clerk/expo >=3.0.0):

app/_layout.tsx
<ClerkProvider
  signInFallbackRedirectUrl="/(home)"
  signUpFallbackRedirectUrl="/(home)"
></ClerkProvider>
BeforeAfter
afterSignInUrlsignInFallbackRedirectUrl
afterSignUpUrlsignUpFallbackRedirectUrl
redirectUrlsignInFallbackRedirectUrl
For forced redirectssignInForceRedirectUrl / signUpForceRedirectUrl

SAML to Enterprise SSO

SAML references are renamed to enterprise SSO throughout the API:

Before (Core 2):

utils/enterprise-auth.tsx
// Core 2 SAML references
const samlAccounts = user.samlAccounts
await signIn.create({ strategy: 'saml', identifier: email })

After (Core 3, @clerk/expo >=3.0.0):

utils/enterprise-auth.tsx
// Core 3 enterprise SSO references
const enterpriseAccounts = user.enterpriseAccounts

In Expo, use useSSO() with the renamed strategy for enterprise SSO flows:

components/EnterpriseSSOButton.tsx
import { useSSO } from '@clerk/expo'

export function EnterpriseSSOButton({ email }: { email: string }) {
  const { startSSOFlow } = useSSO()

  const onPress = async () => {
    const { createdSessionId, setActive } = await startSSOFlow({
      strategy: 'enterprise_sso',
      identifier: email,
    })
    if (createdSessionId && setActive) {
      await setActive({ session: createdSessionId })
    }
  }

  // render button...
}

useOAuth to useSSO

The useOAuth() hook is deprecated. Use useSSO() for browser-based SSO and OAuth flows:

Before (Core 2):

components/OAuthButton.tsx
import { useOAuth } from '@clerk/clerk-expo'
import * as WebBrowser from 'expo-web-browser'

WebBrowser.maybeCompleteAuthSession()

export function GoogleOAuthButton() {
  const { startOAuthFlow } = useOAuth({ strategy: 'oauth_google' })

  const onPress = async () => {
    const { createdSessionId, setActive } = await startOAuthFlow()
    if (createdSessionId && setActive) {
      await setActive({ session: createdSessionId })
    }
  }

  // render button...
}

After (Core 3, @clerk/expo >=3.0.0):

components/SSOButton.tsx
import { useSSO } from '@clerk/expo'

export function GoogleSSOButton() {
  const { startSSOFlow } = useSSO()

  const onPress = async () => {
    const { createdSessionId, setActive } = await startSSOFlow({
      strategy: 'oauth_google',
      redirectUrl: 'your-scheme://callback',
    })
    if (createdSessionId && setActive) {
      await setActive({ session: createdSessionId })
    }
  }

  // render button...
}

Other Renamed APIs

BeforeAfter
client.activeSessionsclient.sessions
ClerkAPIError.kind === 'ClerkApiError'ClerkAPIError.kind === 'ClerkAPIError'
verification.samlAccountverification.enterpriseAccount
userSettings.samluserSettings.enterpriseSSO
import { Clerk }Use useClerk() or getClerkInstance()

Conclusion

With the core package, provider, and hooks migrated to @clerk/expo, your application is now running on Core 3. In Part 2, we cover adopting the new native components, passkeys, offline resilience, and advanced routing protections.

FAQ

Do I have to migrate to @clerk/expo right away?

Core 2 is in long-term support until January 2027, so you don't have to migrate immediately. However, new features and performance improvements are only available in Core 3.

Can I use the @clerk/upgrade CLI to fully migrate my app?

The CLI automates most import path changes and component replacements like SignedIn to Show. However, you must manually update hook logic (like useSignIn) and ClerkProvider configuration.

In this series

  1. Migrating from @clerk/clerk-expo to @clerk/expo — Breaking Changes, Native Components, and the Complete Upgrade Path (you are here)
  2. Migrating from @clerk/clerk-expo to @clerk/expo — Breaking Changes, Native Components, and the Complete Upgrade Path - Part 2