
Migrating from @clerk/clerk-expo to @clerk/expo — Breaking Changes, Native Components, and the Complete Upgrade Path - Part 2
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).
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.
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:
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.
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>:
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:
- Native auth completes and creates a session
- The native session's token syncs to the JS SDK's token cache
- The JS SDK picks up the session
- 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-cryptoConfigure 3 OAuth client IDs in the Google Cloud Console (iOS, Android, Web) and set them as environment variables (Google Sign-In guide).
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-cryptoRegister in the Clerk Dashboard with your Team ID + Bundle ID (Apple Sign-In guide).
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-storeProperties: hasCredentials, userOwnsCredentials, biometricType ('face-recognition' | 'fingerprint' | null). Methods: setCredentials(), clearCredentials(), authenticate().
Workflow:
- User signs in with password
- Call
setCredentials()to store credentials - On future launches, call
authenticate()for biometric sign-in
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>
)
}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 prebuildEnable passkeys in your Clerk Dashboard's authentication settings. Then configure ClerkProvider:
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>
)
}iOS Requirements
- iOS 16+ required for passkeys (Apple added passkey support in iOS 16). The
@clerk/expoconfig plugin raises the iOS deployment target to 17.0 automatically, which already clears this minimum, so no manualexpo-build-propertiesstep 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:
{
"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:
{
"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:
import { useUser } from '@clerk/expo'
function CreatePasskeyButton() {
const { user } = useUser()
const onCreate = async () => {
await user?.createPasskey()
}
// render button...
}Sign in with a passkey:
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):
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):
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.
Experimental Offline Support
For full offline resilience, pass resourceCache to ClerkProvider. It caches authentication state, environment data, and session JWTs using expo-secure-store.
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:
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:
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:
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>
)
}Organizations and Multi-Tenant Authorization
Organizations in Core 3 use the same <Show> component for multi-tenant authorization checks.
Organization Authorization Patterns
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:
Testing Authentication Flows
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: falseis set onuseAuth()and<Show>- Session sync completes within ~3 seconds of native auth
Testing Offline and Error Handling
- Disable network, verify
ClerkOfflineErroris caught (not null) - Test biometric auth if using
useLocalCredentials - Test passkeys on physical devices (not emulators)
Development vs. Production
Troubleshooting Common Migration Issues
Breaking Changes Quick Reference
Common Errors and Fixes
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
- Migrating from @clerk/clerk-expo to @clerk/expo — Breaking Changes, Native Components, and the Complete Upgrade Path
- Migrating from @clerk/clerk-expo to @clerk/expo — Breaking Changes, Native Components, and the Complete Upgrade Path - Part 2 (you are here)