Skip to main content
Articles

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

Author: Roy Anger
Published: (last updated )

This is Part 2 of the migration guide for @clerk/clerk-expo to @clerk/expo (Core 3). Part 1 covered the core API upgrades and hook changes. This part explores adopting the new beta Native Components, platform-native authentication, passkeys, offline support, and multi-tenant authorization.

Step 8: Adopting Native Components (Beta)

Native components are the biggest addition in @clerk/expo 3.1. They render authentication UI using SwiftUI on iOS and Jetpack Compose on Android (Expo Native Components, 2026-03-09).

Note

Native components are in beta. They require a development build and Expo SDK 53+.

AuthView: Native Authentication Interface

AuthView renders a complete sign-in/sign-up flow using platform-native UI. It handles email/password, social login, passkeys, and MFA automatically.

app/(auth)/sign-in.tsx
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'

export default function SignInScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) {
      router.replace('/(home)')
    }
  }, [isSignedIn])

  return <AuthView mode="signInOrUp" />
}

Props:

PropTypeDefaultDescription
mode'signIn' | 'signUp' | 'signInOrUp''signInOrUp'Controls which flow to display
isDismissiblebooleantrueWhen true, shows a dismiss button; when false, the user must complete authentication to close the view
onDismiss() => voidundefinedCalled when the view requests dismissal (the user dismisses it, or the native flow finishes)

Warning

isDismissible defaults to true, which shows AuthView's own dismiss button. When you present AuthView inside a React Native <Modal>, set isDismissible={false} so the modal owns dismissal and you avoid two competing dismiss controls.

AuthView handles social sign-in flows automatically. You don't need useSignInWithGoogle or useSignInWithApple hooks (or their peer dependencies like expo-crypto) when using AuthView.

UserButton: Native Profile Avatar

UserButton displays the user's avatar as a tappable circle. Tapping opens a native profile modal.

app/(home)/_layout.tsx
import { Stack } from 'expo-router'
import { UserButton } from '@clerk/expo/native'
import { View } from 'react-native'

export default function HomeLayout() {
  return (
    <Stack
      screenOptions={{
        headerRight: () => (
          <View style={{ width: 36, height: 36, borderRadius: 18, overflow: 'hidden' }}>
            <UserButton />
          </View>
        ),
      }}
    >
      <Stack.Screen name="index" options={{ title: 'Home' }} />
    </Stack>
  )
}

UserButton has no props. The parent container controls its size and shape. Sign-out is handled automatically and synced with the JS SDK.

UserProfileView: Full Profile Management

UserProfileView renders Clerk's full profile and account management UI natively. Render it inline, either in its own route or inside a React Native <Modal>:

app/(home)/profile.tsx
import { UserProfileView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'

export default function ProfileScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })

  if (!isSignedIn) return null

  return <UserProfileView style={{ flex: 1 }} />
}

UserProfileView provides personal info, security settings, account switching, MFA, passkeys, connected accounts, and sign-out. Like AuthView, it accepts isDismissible (default true); set isDismissible={false} when you present it inside your own <Modal> so the modal owns dismissal.

Session Synchronization

Native components run through a separate native SDK. ClerkProvider keeps the native and JavaScript sessions in sync automatically, so you don't add any sync component yourself:

  1. Native auth completes and creates a session
  2. The native session's token syncs to the JS SDK's token cache
  3. The JS SDK picks up the session
  4. React hooks reflect the new auth state

Use useEffect to react to auth state changes. Don't use imperative callbacks. Always set treatPendingAsSignedOut to false with native components to avoid a flash of signed-out content during sync.

Web Compatibility

For Expo web projects, use @clerk/expo/web which provides prebuilt web components (SignIn, SignUp, UserButton, etc.). These throw on native. Keep native and web paths separate with platform checks.

Step 9: Native Authentication Hooks

Google Sign-In Without a WebView

useSignInWithGoogle uses platform-native APIs: ASAuthorization on iOS, Credential Manager on Android. No browser redirect.

Install the required peer dependency:

npx expo install expo-crypto

Configure 3 OAuth client IDs in the Google Cloud Console (iOS, Android, Web) and set them as environment variables (Google Sign-In guide).

components/GoogleSignIn.tsx
import { useSignInWithGoogle } from '@clerk/expo/google'
import { useRouter } from 'expo-router'
import { Alert, Platform, Pressable, Text } from 'react-native'

export function GoogleSignInButton() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()
  const router = useRouter()

  if (Platform.OS !== 'ios' && Platform.OS !== 'android') return null

  const onPress = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: any) {
      if (err.code === 'SIGN_IN_CANCELLED' || err.code === '-5') return
      Alert.alert('Error', err.message || 'Google sign-in failed')
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Google</Text>
    </Pressable>
  )
}

Apple Sign-In (iOS Only)

Install the required peer dependencies:

npx expo install expo-apple-authentication expo-crypto

Register in the Clerk Dashboard with your Team ID + Bundle ID (Apple Sign-In guide).

components/AppleSignIn.tsx
import { useSignInWithApple } from '@clerk/expo/apple'
import { useRouter } from 'expo-router'
import { Alert, Platform, Pressable, Text } from 'react-native'

export function AppleSignInButton() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()
  const router = useRouter()

  if (Platform.OS !== 'ios') return null

  const onPress = async () => {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: any) {
      if (err.code === 'ERR_REQUEST_CANCELED') return
      Alert.alert('Error', err.message || 'Apple sign-in failed')
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Apple</Text>
    </Pressable>
  )
}

Biometric Authentication with Local Credentials

useLocalCredentials enables biometric authentication (Face ID, fingerprint) for password-based sign-in. It stores encrypted credentials on-device after the first password sign-in.

Install the required peer dependencies:

npx expo install expo-local-authentication expo-secure-store

Properties: hasCredentials, userOwnsCredentials, biometricType ('face-recognition' | 'fingerprint' | null). Methods: setCredentials(), clearCredentials(), authenticate().

Workflow:

  1. User signs in with password
  2. Call setCredentials() to store credentials
  3. On future launches, call authenticate() for biometric sign-in
app/(auth)/sign-in.tsx
import { useSignIn, useClerk } from '@clerk/expo'
import { useLocalCredentials } from '@clerk/expo/local-credentials'
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 { setActive } = useClerk()
  const { hasCredentials, setCredentials, authenticate, biometricType } = useLocalCredentials()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const router = useRouter()

  // Biometric sign-in for returning users.
  // authenticate() returns a SignInResource (Core 2 type), so use
  // setActive() from useClerk() instead of signIn.finalize().
  const onBiometricSignIn = async () => {
    const result = await authenticate()
    if (result.status === 'complete') {
      await setActive({ session: result.createdSessionId })
      router.replace('/')
    }
  }

  // Password sign-in with credential storage (Core 3 authentication hook API)
  const onPasswordSignIn = async () => {
    await signIn.password({ emailAddress: email, password })

    if (signIn.status === 'complete') {
      // Store credentials for future biometric sign-in
      await setCredentials({ identifier: email, password })
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          router.push(decorateUrl('/') as Href)
        },
      })
    }
  }

  return (
    <View>
      {hasCredentials && biometricType ? (
        <Pressable onPress={onBiometricSignIn} disabled={fetchStatus === 'fetching'}>
          <Text>
            {biometricType === 'face-recognition'
              ? 'Sign in with Face ID'
              : 'Sign in with Fingerprint'}
          </Text>
        </Pressable>
      ) : null}

      <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />
      {errors?.fields?.identifier ? <Text>{errors.fields.identifier.message}</Text> : null}
      <Pressable onPress={onPasswordSignIn} disabled={fetchStatus === 'fetching'}>
        <Text>Sign In</Text>
      </Pressable>
    </View>
  )
}

Note

Local credentials only work for password-based sign-in on native platforms (not web). See the Local Credentials guide.

Step 10: Passkeys Configuration

Passkeys provide passwordless authentication using WebAuthn. This feature is experimental in @clerk/expo.

Installation

npx expo install @clerk/expo-passkeys
npx expo prebuild

Enable passkeys in your Clerk Dashboard's authentication settings. Then configure ClerkProvider:

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

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
      __experimental_passkeys={passkeys}
    >
      <Slot />
    </ClerkProvider>
  )
}

Warning

@clerk/expo-passkeys has a peer dependency of expo >=53 <55, which is narrower than @clerk/expo's range of >=53 <56. If you're on Expo SDK 55, check for an updated version of @clerk/expo-passkeys before installing.

iOS Requirements

  • iOS 16+ required for passkeys (Apple added passkey support in iOS 16). The @clerk/expo config plugin raises the iOS deployment target to 17.0 automatically, which already clears this minimum, so no manual expo-build-properties step is needed.
  • Register your app in Clerk Dashboard with App ID Prefix + Bundle ID (from Apple Developer portal's Identifiers page)
  • Configure associated domains in app.json:
app.json
{
  "expo": {
    "ios": {
      "associatedDomains": [
        "applinks:<YOUR_FRONTEND_API_URL>",
        "webcredentials:<YOUR_FRONTEND_API_URL>"
      ]
    }
  }
}

Android Requirements

  • Android 9+ required
  • Physical device only. Emulators don't support passkeys.
  • Register in Clerk Dashboard with your package name and SHA256 certificate fingerprints
  • Configure intent filters:
app.json
{
  "expo": {
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [{ "scheme": "https", "host": "<YOUR_FRONTEND_API_URL>" }],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

Verify setup with Google's Statement List Generator tool.

Passkey Methods (Core 3)

Create a passkey:

components/CreatePasskey.tsx
import { useUser } from '@clerk/expo'

function CreatePasskeyButton() {
  const { user } = useUser()

  const onCreate = async () => {
    await user?.createPasskey()
  }

  // render button...
}

Sign in with a passkey:

components/PasskeySignIn.tsx
import { useSignIn } from '@clerk/expo'
import { useRouter, type Href } from 'expo-router'

function PasskeySignIn() {
  const { signIn } = useSignIn()
  const router = useRouter()

  const onSignIn = async () => {
    await signIn.passkey({ flow: 'discoverable' })

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

  // render button...
}

Flow options: 'discoverable' (requires user interaction) or 'autofill' (prompts before interaction).

Step 11: Offline Support and ClerkOfflineError

Breaking Change: getToken() Behavior

In Core 2, getToken() returned null when offline. This was ambiguous: it could mean signed out or offline. Core 3 throws ClerkOfflineError after a ~15 second retry period, making the distinction explicit.

Before (Core 2):

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

function useApiClient() {
  const { getToken } = useAuth()

  const fetchData = async () => {
    const token = await getToken()
    if (!token) {
      // Could be signed out OR offline. No way to tell.
      return null
    }
    // make API call with token
  }
}

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

utils/api.tsx
import { useAuth } from '@clerk/expo'
import { ClerkOfflineError } from '@clerk/react/errors'

function useApiClient() {
  const { getToken } = useAuth()

  const fetchData = async () => {
    try {
      const token = await getToken()
      if (!token) {
        // Definitively signed out
        return null
      }
      // make API call with token
    } catch (error) {
      if (ClerkOfflineError.is(error)) {
        // Definitively offline. Show cached data or retry UI.
        return null
      }
      throw error
    }
  }
}

Expo's custom useAuth override adds JWT caching: if a network error occurs, it returns the cached token instead of throwing. This makes offline transitions smoother.

Tip

ClerkOfflineError.is() is for getToken() calls specifically. For custom sign-in/sign-up flows, use isClerkRuntimeError from @clerk/expo with the network_error code instead:

import { isClerkRuntimeError } from '@clerk/expo'

try {
  await signIn.password({ emailAddress: email, password })
} catch (err) {
  if (isClerkRuntimeError(err) && err.code === 'network_error') {
    // Handle offline scenario in custom flows
  }
}

See the Offline Support guide for details.

Experimental Offline Support

For full offline resilience, pass resourceCache to ClerkProvider. It caches authentication state, environment data, and session JWTs using expo-secure-store.

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

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
      __experimental_resourceCache={resourceCache}
    >
      <Slot />
    </ClerkProvider>
  )
}

The resource cache stores authentication state using expo-secure-store for encrypted persistent storage (Offline Support, 2024-12-12).

Token Refresh Strategy

Clerk uses a hybrid auth model: client tokens (long-lived, on the FAPI domain) and session tokens (60-second expiry, on the app domain). The SDK handles token refresh automatically in the background, so sessions stay valid without manual intervention (How Clerk Works). No code changes required.

Step 12: Expo Router Protected Routes

Layout-Based Route Protection

Use route groups with _layout.tsx files for authentication-based routing:

app/(home)/_layout.tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function HomeLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return <Stack />
}

The auth route layout redirects signed-in users away:

app/(auth)/_layout.tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function AuthLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (isSignedIn) {
    return <Redirect href="/(home)" />
  }

  return <Stack />
}

Authorization-Based Route Protection

Protect admin routes using <Show> with organization roles:

app/(home)/admin/_layout.tsx
import { Show } from '@clerk/expo'
import { Stack } from 'expo-router'
import { Text } from 'react-native'

export default function AdminLayout() {
  return (
    <Show when={{ role: 'org:admin' }} fallback={<Text>Not authorized</Text>}>
      <Stack />
    </Show>
  )
}

Tip

useAuth() and useUser() work with any navigation library (React Navigation, etc.), not only Expo Router. The auth state hooks are navigation-agnostic.

Organizations and Multi-Tenant Authorization

Organizations in Core 3 use the same <Show> component for multi-tenant authorization checks.

Organization Authorization Patterns

app/(home)/dashboard.tsx
import { Show } from '@clerk/expo'
import { Text, View } from 'react-native'

export default function Dashboard() {
  return (
    <View>
      <Show when={{ role: 'org:admin' }}>
        <Text>Admin panel: manage members and settings</Text>
      </Show>

      <Show when={{ permission: 'org:invoices:create' }}>
        <Text>Create and manage invoices</Text>
      </Show>

      <Show when={{ feature: 'premium_access' }}>
        <Text>Premium content for subscribers</Text>
      </Show>

      <Show when={{ plan: 'enterprise' }}>
        <Text>Enterprise features: SSO, audit logs</Text>
      </Show>

      <Show
        when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}
        fallback={<Text>Access restricted</Text>}
      >
        <Text>Billing management</Text>
      </Show>
    </View>
  )
}

User Management

UserProfileView from @clerk/expo/native provides self-service user management including personal info, security settings, and account switching. Render it inline in its own route or inside a React Native <Modal>, as shown in Step 8.

For session management, the native SDK handles session lifecycle, switching, and sign-out automatically when using native components.

Testing and Validation

Migration Checklist

Run through this checklist after completing all migration steps:

Checklist

Testing Authentication Flows

FlowWhat to Test
Email/password sign-insignIn.password() completes, signIn.finalize() navigates correctly
Email/password sign-upsignUp.password(), email verification, signUp.finalize()
OAuth (native)Google and Apple native flows on device
OAuth (browser)useSSO flows with browser redirect
MFAneeds_second_factor status, signIn.mfa.verifyEmailCode()
Client Trustneeds_client_trust on new device with password
Sign-outSession cleanup, UI updates

Testing Authorization

  • Verify <Show> with role-based conditions shows/hides correctly
  • Verify <Show> with permission-based conditions
  • Verify fallback content renders for unauthorized users
  • Test organization switching and role changes in real-time

Testing Native Components

  • AuthView renders and completes auth flow on iOS and Android
  • UserButton displays avatar, opens profile modal
  • treatPendingAsSignedOut: false is set on useAuth() and <Show>
  • Session sync completes within ~3 seconds of native auth

Testing Offline and Error Handling

  • Disable network, verify ClerkOfflineError is caught (not null)
  • Test biometric auth if using useLocalCredentials
  • Test passkeys on physical devices (not emulators)

Development vs. Production

EnvironmentHow to Test
Development buildnpx expo run:ios / npx expo run:android
Production-likeEAS Build
API keysSwitch from pk_test_ to pk_live_ (Expo deployment guide)
Native featuresVerify in production builds via EAS

Troubleshooting Common Migration Issues

Breaking Changes Quick Reference

ChangeBefore (Core 2)After (Core 3)
Package name@clerk/clerk-expo@clerk/expo
Control componentsSignedIn / SignedOut / ProtectShow
Sign-in APIsignIn.create() + setActive()signIn.password() + signIn.finalize()
Sign-up APIsignUp.create() + setActive()signUp.password() + signUp.finalize()
Environment variableCLERK_FRONTEND_APIEXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
Token offline behaviorReturns nullThrows ClerkOfflineError
Expo SDK minimum50.0.0+53.0.0+
Node.js minimum18.17.0+20.9.0+
OAuth hooksuseOAuth()useSSO()
Native OAuth imports@clerk/clerk-expo@clerk/expo/apple, @clerk/expo/google
Appearance configappearance.layoutappearance.options
Redirect propsafterSignInUrlsignInFallbackRedirectUrl
SAML strategystrategy: 'saml'strategy: 'enterprise_sso'
Error kind'ClerkApiError''ClerkAPIError'
Active sessionsclient.activeSessionsclient.sessions
Clerk exportimport { Clerk }useClerk() / getClerkInstance()
setActive callbackbeforeEmitnavigate
Passkey sign-insignIn.authenticateWithPasskey()signIn.passkey()

Common Errors and Fixes

ErrorCauseFix
Cannot find module @clerk/clerk-expoPackage not renamednpx expo install @clerk/expo
publishableKey is requiredNot passed explicitlyAdd EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to .env, pass to ClerkProvider
Native components don't renderUsing Expo GoRun npx expo run:ios or npx expo run:android
Tokens lost on restartexpo-secure-store missingnpx expo install expo-secure-store, add tokenCache
OAuth failsNative API not enabledEnable at Dashboard's Native Applications page
Passkeys fail on emulatorNot supportedUse a physical device
ClerkOfflineError not caughtUsing null-check patternSwitch to try/catch with ClerkOfflineError.is(error)
App crashes in productionpublishableKey missingEnv vars aren't inlined in RN builds; pass explicitly

Conclusion

Upgrading to @clerk/expo modernizes your authentication flow and unlocks native components, passkeys, and offline resilience. Once you've completed these steps, validate every flow against the Migration Checklist and testing tables above before shipping to production.

FAQ

Do native components work in Expo Go?

No, native features like AuthView, UserButton, and platform-native OAuth require a development build. They cannot be run inside Expo Go.

Why does getToken() throw an error when offline?

In Core 3, getToken() throws a ClerkOfflineError after retrying, making it explicitly clear that the network is unavailable, whereas Core 2 returned null which was ambiguous with being signed out.

In this series

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