
Migrating from @clerk/clerk-expo to @clerk/expo — Breaking Changes, Native Components, and the Complete Upgrade Path
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
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:
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:iosOr for Android:
npx expo run:androidFor CI/CD, use EAS Build.
Clerk Dashboard Configuration
Before migrating, configure your Clerk Dashboard:
- Enable Native API on the Native Applications page (deployment guide)
- Register your apps: iOS (Team ID + Bundle ID), Android (package name)
- Configure OAuth credentials for Google and Apple sign-in if using native OAuth
- 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/upgradeOther package managers:
pnpm dlx @clerk/upgrade
# or
yarn dlx @clerk/upgrade
# or
bunx @clerk/upgradeThe CLI supports --sdk and --dir flags for targeted scanning in monorepos.
What the CLI Handles
- Package rename:
@clerk/clerk-expoto@clerk/expo - Import path updates across all files
SignedIn,SignedOut,ProtecttoShowcomponent replacementsClerkProviderpositioning- Re-exports, aliased imports, and monorepo files
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-storeFor native components, add development dependencies:
npx expo install expo-auth-session expo-web-browser expo-dev-clientImport Path Reference Table
Every import from @clerk/clerk-expo changes to @clerk/expo or one of its 14 subpath exports:
Before (Core 2):
import { useAuth, useUser, SignedIn, SignedOut } from '@clerk/clerk-expo'After (Core 3, @clerk/expo >=3.0.0):
import { useAuth, useUser, Show } from '@clerk/expo'Removed Exports
Clerkexport removed. UseuseClerk()inside components orgetClerkInstance()outside them.@clerk/typesdeprecated. Types are now exported from SDK packages via@clerk/shared/types.@clerk/expo/secure-storedeprecated. Use@clerk/expo/resource-cacheinstead.
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):
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):
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-storeapp.json Plugin Configuration
The @clerk/expo config plugin automatically adds the native SDKs (clerk-ios and clerk-android) and configures required build settings.
{
"expo": {
"plugins": [
"expo-secure-store",
[
"@clerk/expo",
{
"appleSignIn": true
}
]
]
}
}Plugin options:
The plugin handles these automatically:
- iOS: Adds
clerk-iosvia SPM (ClerkKit + ClerkKitUI), injectsClerkViewFactory.swift, modifiesAppDelegate.swift - Android: Adds META-INF exclusions, Kotlin metadata version flags
- Google Sign-In: Reads
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEMEfor 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.
Authentication State Checks
Before (Core 2):
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):
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):
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):
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
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:
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):
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):
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()replacessignIn.create({ identifier, password }) - Structured errors:
errors.fields.identifier?.messagereplacestry/catchwitherr.errors?.[0]?.message - fetchStatus:
'idle'or'fetching', useful for disabling buttons during API calls - finalize replaces setActive:
signIn.finalize({ navigate })replacessetActive({ 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):
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):
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>
)
}setActive Callback Changes
The beforeEmit callback is replaced by navigate. The new callback receives session and decorateUrl:
Before (Core 2):
await setActive({
session: result.createdSessionId,
beforeEmit: (session) => {
router.push('/(home)')
},
})After (Core 3, @clerk/expo >=3.0.0):
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):
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):
import { useAuth } from '@clerk/expo'
async function useApiToken() {
const { getToken } = useAuth()
const token = await getToken() // always a function, use try/catch for errors
}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.
// 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):
<ClerkProvider
appearance={{
layout: {
showOptionalFields: true,
},
}}
></ClerkProvider>After (Core 3, @clerk/expo >=3.0.0):
<ClerkProvider
appearance={{
options: {
showOptionalFields: false,
},
}}
></ClerkProvider>Other appearance changes:
showOptionalFieldsdefault changed fromtruetofalse. Set it explicitly if you want optional fields visible.colorRingandcolorModalBackdropnow render at full opacity. Usergba()values to restore previous behavior.- Experimental prefixes standardized. All
experimental_andexperimental__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):
<ClerkProvider afterSignInUrl="/(home)" afterSignUpUrl="/(home)"></ClerkProvider>After (Core 3, @clerk/expo >=3.0.0):
<ClerkProvider
signInFallbackRedirectUrl="/(home)"
signUpFallbackRedirectUrl="/(home)"
></ClerkProvider>SAML to Enterprise SSO
SAML references are renamed to enterprise SSO throughout the API:
Before (Core 2):
// Core 2 SAML references
const samlAccounts = user.samlAccounts
await signIn.create({ strategy: 'saml', identifier: email })After (Core 3, @clerk/expo >=3.0.0):
// Core 3 enterprise SSO references
const enterpriseAccounts = user.enterpriseAccountsIn Expo, use useSSO() with the renamed strategy for enterprise SSO flows:
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):
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):
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
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
- Migrating from @clerk/clerk-expo to @clerk/expo — Breaking Changes, Native Components, and the Complete Upgrade Path (you are here)
- Migrating from @clerk/clerk-expo to @clerk/expo — Breaking Changes, Native Components, and the Complete Upgrade Path - Part 2