
How to use SwiftUI components in a React Expo and Clerk app - Part 2
Part 2 of 2. Start with How to use SwiftUI components in a React Expo and Clerk app.
This is the second part of a two-part series on building a React Native app with Expo and Clerk's native SwiftUI components. In Part 1, we covered the architectural benefits of native authentication, project setup, and implementing Google Sign-In with <AuthView />. In this part, we will cover user management, Apple Sign-In, route protection, and advanced topics.
Step 5: User management with <UserButton /> and <UserProfileView />
Post-auth UX usually needs two things: a visible affordance for the user to manage their account, and a place to do it. <UserButton /> is the affordance; <UserProfileView /> is the place.
Place <UserButton /> in the header
UserButton has no props. Size is controlled by the parent <View> using width, height, borderRadius, and overflow: 'hidden' (UserButton.tsx source). Put it in the Expo Router header's headerRight:
import { UserButton } from '@clerk/expo/native'
import { Stack } from 'expo-router'
import { View } from 'react-native'
export default function HomeLayout() {
return (
<Stack
screenOptions={{
headerRight: () => (
<View style={{ width: 44, height: 44, borderRadius: 22, overflow: 'hidden' }}>
<UserButton />
</View>
),
}}
>
<Stack.Screen name="index" options={{ title: 'Home' }} />
</Stack>
)
}Tapping the button opens the native profile modal, a real SwiftUI sheet on iOS, not a React Native <Modal />.
Render <UserProfileView /> for a dedicated profile screen
Since @clerk/expo 3.4.0, the profile view is app-presented: you render <UserProfileView /> yourself inside a route, sheet, or React Native <Modal />, rather than calling an imperative "present" hook. (Early betas exposed a useUserProfileModal() hook; it was removed in 3.4.0 when the native views moved to app-driven presentation.) The most common placement is a dedicated profile route:
import { UserProfileView } from '@clerk/expo/native'
export default function ProfileScreen() {
return <UserProfileView style={{ flex: 1 }} />
}To open the profile from a settings row instead, navigate to that route, or toggle a state flag that renders <UserProfileView /> inside a React Native <Modal />. UserProfileView is the only native component that accepts a style prop; it also takes isDismissible (default true) and an onDismiss callback (UserProfileView.tsx source). Set isDismissible={false} when you wrap it in your own <Modal /> so the native view doesn't render a second dismiss control. Visual theming is still controlled by clerk-theme.json.
Sign-out flow
Both <UserProfileView /> and the profile opened by <UserButton /> include a Sign Out control. @clerk/expo keeps auth state in sync between the native SDK and the JavaScript Clerk instance in both directions, so signing out flips useAuth().isSignedIn to false on its own, and any route guard that reads it (like the Stack.Protected gate in Step 7) re-renders and returns the user to the sign-in route with no event wiring. This is why the early-beta useNativeAuthEvents() hook was removed: automatic sync made a dedicated sign-out event unnecessary.
To run your own side effects on sign-out (analytics, clearing local state), watch isSignedIn with useAuth() in an effect:
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
export function useSignOutRedirect() {
const { isSignedIn, isLoaded } = useAuth()
const router = useRouter()
useEffect(() => {
if (isLoaded && !isSignedIn) router.replace('/sign-in')
}, [isLoaded, isSignedIn, router])
}Step 6: Add Apple Sign-In
Apple Sign-In is the only provider on iOS that renders a true native credential sheet via ASAuthorizationController. On Android, Apple falls back to an OAuth browser flow that <AuthView /> handles transparently. The @clerk/expo config plugin writes the necessary iOS entitlement at prebuild time, so the wiring is minimal.
Enable Apple in the Dashboard
In the Clerk Dashboard, go to Social Connections → Apple and enable it. For development, that's enough; you can test on a physical iPhone signed into a real Apple ID.
For production you'll also supply the Apple-side credentials (Clerk Apple social connection setup):
- Apple Team ID.
- Services ID (created in the Apple Developer portal).
- Key ID and
.p8private key file (one-time download; back it up immediately per Apple's private-key guide).
Rebuild the dev build
The @clerk/expo plugin adds the com.apple.developer.applesignin entitlement at prebuild time, which is a native change. Rebuild:
npx expo run:ios --deviceA simulator rebuild works for most of the flow, but physical-device testing is required for reliable end-to-end Apple Sign-In (see prerequisites).
Testing without a physical iPhone
If you don't have a device on hand, you can still make progress. The Sign in with Apple sheet will render on the Simulator when an Apple ID is signed into Settings → [your name] → Media & Purchases, and the initial authorization sometimes completes if that Apple ID has 2FA enabled (Apple Developer Forums discussion). getCredentialStateAsync always throws on the Simulator, though, so any logic that checks credential state on subsequent launches will fail there.
Two pragmatic fallbacks:
- Mock the native modules for unit tests and CI. Expo's official mocking guide covers the
jest-expopreset and module mocks so your Apple Sign-In tests run green on a laptop with no device attached. - Borrow, ad-hoc, or rent a real device before release. A personal device, an internal-distribution EAS build on a teammate's phone, or a cloud device farm (AWS Device Farm, BrowserStack, Firebase Test Lab) all work for pre-release verification. Apple's App Review requires a working Sign in with Apple implementation (App Store Review Guideline 4.8), so at least one physical-device pass is mandatory before shipping to the App Store.
Test Apple Sign-In with <AuthView />
<AuthView /> detects that Apple is enabled for your Clerk instance and renders the ASAuthorizationAppleIDButton automatically (Apple Developer). Tap it and the OS presents the credential sheet, biometric prompt, email relay option, all rendered by ASAuthorizationController. Clerk handles the sign-in → sign-up transfer flow internally, so a returning user with a Clerk account is signed in; a new user triggers a sign-up with the name and email that Apple provides on first authorization.
One OS constraint to plan for: Apple only returns the user's fullName and email on the first authorization. Subsequent sign-ins omit them. Clerk stores them automatically on first auth, so you don't need to handle this in your app, but it's worth knowing if you ever see a user with no name on re-sign-in during testing.
Optional: custom Apple Sign-In UI with useSignInWithApple()
If you need a branded Apple button that lives outside <AuthView /> (custom placement, haptics, extra unsafeMetadata, or interleaving with non-Clerk screens), use the useSignInWithApple() hook from the /apple subpath. @clerk/expo 3.0.0 moved the hook to a dedicated /apple entry point, so projects that don't need Apple Sign-In don't bundle expo-apple-authentication and expo-crypto.
Install the two additional packages and register expo-apple-authentication as an Expo config plugin so its entitlements and native dependencies are applied at prebuild (Clerk Sign in with Apple (Expo); expo-apple-authentication config plugin):
npx expo install expo-apple-authentication expo-cryptoRegister the plugin in app.json:
{
"expo": {
"plugins": [
"expo-router",
"expo-secure-store",
"expo-apple-authentication",
["@clerk/expo", {}]
]
}
}Then build the custom button. Import the hook from @clerk/expo/apple and gate rendering on Platform.OS === 'ios' because the hook is iOS-only (useSignInWithApple.ios.ts):
import { useSignInWithApple } from '@clerk/expo/apple'
import { Platform, Pressable, Text } from 'react-native'
import { useRouter } from 'expo-router'
export function AppleButton() {
const { startAppleAuthenticationFlow } = useSignInWithApple()
const router = useRouter()
if (Platform.OS !== 'ios') return null
async function onPress() {
try {
const { createdSessionId, setActive } = await startAppleAuthenticationFlow()
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId })
router.replace('/')
}
} catch (err: any) {
if (err.code !== 'ERR_REQUEST_CANCELED') console.error(err)
}
}
return (
<Pressable onPress={onPress}>
<Text>Sign in with Apple</Text>
</Pressable>
)
}The hook auto-generates a cryptographic nonce via expo-crypto, requests FULL_NAME + EMAIL scopes, and transparently transfers a sign-in into a sign-up when the Apple ID is new to Clerk. User cancellation is usually swallowed inside the hook and resolves with createdSessionId: null (no throw), but the ERR_REQUEST_CANCELED guard handles edge paths where it surfaces (Clerk useSignInWithApple reference).
For Android, Apple authentication goes through useSSO({ strategy: 'oauth_apple' }) instead, which uses a browser flow under the hood (useSSO() reference).
When to pick useSignInWithApple vs <AuthView />
<AuthView /> is the default path: drop-in native UI for Apple + Google + email/password + MFA + recovery, no manual dependency wiring, automatic entitlement management. Pick useSignInWithApple() only when you need a fully custom button UI that composes into an otherwise non-Clerk screen. Mixing the two inside a single sign-in flow is possible but usually unnecessary.
Apple first shipped the useSignInWithApple() hook for Expo on 2025-11-13 (Clerk changelog, 2025-11-13).
Step 7: Protect routes with Expo Router
Only signed-in users should reach the home stack. The cleanest pattern on Expo Router v5+ is Stack.Protected (Expo Router authentication guide; Stack.Protected reference):
import { Stack } from 'expo-router'
import { useAuth } from '@clerk/expo'
export default function RootLayout() {
const { isSignedIn, isLoaded } = useAuth()
if (!isLoaded) return null
return (
<Stack>
<Stack.Protected guard={isSignedIn}>
<Stack.Screen name="(home)" />
</Stack.Protected>
<Stack.Protected guard={!isSignedIn}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
</Stack>
)
}The isLoaded check prevents a flash of the wrong route while Clerk's hydration completes. On Expo SDK 52 and earlier, use the legacy pattern with useEffect and router.replace(). Stack.Protected shipped with Expo Router v5.
If you prefer render-level gating instead of navigation-level gating, Clerk ships a <Show> component that replaces the legacy <SignedIn> / <SignedOut> / <Protect> triplet (Clerk <Show> reference). Both approaches work; Stack.Protected is usually cleaner for mobile flows.
Under the hood: the Core 3 Signal API
Clerk Core 3 shipped on 2026-03-03 (Clerk changelog, 2026-03-03), and @clerk/expo 3.1 brought it to Expo alongside the native components on 2026-03-09 (Clerk changelog, 2026-03-09). The Signal API is a redesign of useSignIn, useSignUp, useCheckout, and useWaitlist. Each hook returns a reactive *Future object (SignInFuture, SignUpFuture) that triggers re-renders automatically and exposes three structured fields:
fetchStatus('idle' | 'fetching') replaces manualsetIsLoading(true)booleans.status('needs_first_factor' | 'needs_second_factor' | 'complete'and so on) tells you where you are in the flow.errors.fieldsgives you parsed, field-scoped errors, no try/catch parsing ofClerkAPIError[].
Clerk recommends the factor-specific methods over the lower-level signIn.create(), which is reserved for advanced multi-step flows. A passwordless email-code sign-in is three calls:
signIn.emailCode.sendCode({ emailAddress }) // send a one-time code to the user
signIn.emailCode.verifyCode({ code }) // verify the code they entered
signIn.finalize({ navigate }) // set the new session active (replaces setActive())Every other first factor follows the same shape: signIn.password({ identifier, password }), signIn.passkey(), signIn.mfa.verifyTOTP({ code }), and so on (Clerk MFA custom flow guide). Each method resolves to { error } rather than throwing. The navigate callback passed to finalize() receives { session, decorateUrl } so you can branch on session.currentTask (forced MFA setup, org selection, and similar "requires additional step" branches).
A minimal custom email-code screen sends the code, then verifies it:
import { useSignIn } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import { Text, TextInput, Button, View } from 'react-native'
export default function CustomSignIn() {
const { signIn, errors, fetchStatus } = useSignIn()
const router = useRouter()
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
async function sendCode() {
await signIn.emailCode.sendCode({ emailAddress: email })
}
async function verifyCode() {
await signIn.emailCode.verifyCode({ code })
if (signIn.status === 'complete') {
await signIn.finalize({ navigate: () => router.replace('/') })
}
}
return (
<View>
<TextInput
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
{errors.fields.identifier && <Text>{errors.fields.identifier.message}</Text>}
<Button title="Send code" onPress={sendCode} />
<TextInput value={code} onChangeText={setCode} keyboardType="number-pad" />
{errors.fields.code && <Text>{errors.fields.code.message}</Text>}
<Button title={fetchStatus === 'fetching' ? 'Verifying...' : 'Verify'} onPress={verifyCode} />
</View>
)
}Legacy Core 2 required wrapping each call in try { ... } catch (err) { /* parse ClerkAPIError[] */ }. Core 3 exposes parsed errors on the hook itself (errors.fields.identifier?.message, errors.fields.code?.message, plus errors.global and raw errors.raw), so the component code stays linear.
<AuthView /> does not call the JavaScript Signal API. It renders through the native Clerk SDKs (clerk-ios SwiftUI, clerk-android Compose), which implement the equivalent flow natively. When the native SDK completes authentication, @clerk/expo syncs the session into the JS Clerk instance, so useAuth(), useUser(), and useSession() all reflect the new state. The Signal API hooks are the custom-UI escape hatch when you need to render the sign-in screen yourself.
One stability caveat worth flagging: the SignInFuture instance does not have a stable identity across flow steps. If you reference signIn inside useEffect, useCallback, or useMemo, include it in the dependency array explicitly rather than relying on React identity stability (SignInFuture reference).
Theming the native components
@clerk/expo 3.2.0 (shipped 2026-04-16) added a theme plugin option that points at a JSON file. Tokens are embedded at prebuild time, so the customization lives at the native layer rather than JS runtime.
Create clerk-theme.json at the project root:
{
"colors": {
"primary": "#6C47FF",
"background": "#FFFFFF",
"foreground": "#0A0A0A"
},
"darkColors": {
"background": "#121212",
"foreground": "#F5F5F5"
},
"design": {
"borderRadius": 12
}
}Reference it in the plugin config:
["@clerk/expo", { "theme": "./clerk-theme.json" }]Seventeen color tokens are available in the light colors block: primary, background, input, danger, success, warning, foreground, mutedForeground, primaryForeground, inputForeground, neutral, border, ring, muted, shadow, plus secondaryButtonBackground and secondaryButtonForeground (both added in @clerk/expo 3.6.1). The darkColors block accepts the same tokens (override any you want), plus design.borderRadius and an iOS-only design.fontFamily. Rebuild after changing the file because values are baked in at prebuild time (NativeComponentQuickstart clerk-theme.json).
Offline session rehydration
Mobile apps live with flaky networks. @clerk/expo ships an experimental resource cache that persists environment, client state, and the last session JWT to expo-secure-store, so the app boots into an authenticated state on cold start without a network roundtrip. Opt in by passing resourceCache as the __experimental_resourceCache prop on <ClerkProvider> (Clerk offline support):
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { resourceCache } from '@clerk/expo/resource-cache'
import { Stack } from 'expo-router'
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!
export default function RootLayout() {
return (
<ClerkProvider
publishableKey={publishableKey}
tokenCache={tokenCache}
__experimental_resourceCache={resourceCache}
>
<Stack />
</ClerkProvider>
)
}Three things are cached: the Clerk environment (enabled auth strategies, organization settings, feature flags), client state (active sessions, the user object), and the session JWT. Behind the scenes, resourceCache chunks values across expo-secure-store slots with keychainAccessible: AFTER_FIRST_UNLOCK, so the cache is readable only after the user has unlocked the device at least once per boot (resource-cache.ts source).
What it does and does not do:
- Rehydration, yes. On a cold start with no network,
useAuth()resolves from cache andgetToken()returns the last cached JWT. Your gated routes render immediately. - Offline sign-in, no.
signIn.create(),signIn.password(),<AuthView />, and the native social flows all still require network. The resource cache accelerates the "already signed in" path, not the first authentication. - Staleness applies. Cached tokens can be stale until the next refresh, so always treat server-side verification as the source of truth when gating sensitive data.
The __experimental_ prefix reflects that the shape may change. Clerk's docs warn: "It is subject to change in future updates, so use it cautiously in production environments" (Clerk offline support). The older @clerk/expo/secure-store export covered similar ground and is now deprecated in favor of resourceCache.
Troubleshooting
Most native-component issues fall into a handful of buckets. Here's what to check first.
"Native module not found" or TurboModuleRegistry errors. You're running in Expo Go or an old dev build. Rebuild with npx expo run:ios or npx expo run:android.
Google Sign-In error code: 10 on Android. The SHA-1 fingerprint you registered in the Google Cloud Console doesn't match the signing key of the build currently on the device. Re-run ./gradlew signingReport, copy the debug SHA-1, and update the Android OAuth client ID. Release builds use a different fingerprint (React Native Google Sign-In, Setting Up).
Apple Sign-In "hangs" on the simulator. You're hitting the Simulator's getCredentialStateAsync gap. Move to a physical iPhone signed into a real Apple ID (expo-apple-authentication docs).
Kotlin metadata version mismatch on Android. Fixed in @clerk/expo 3.1.5. The config plugin now adds the -Xskip-metadata-version-check Kotlin compiler flag at prebuild time, so builds against Expo SDK 54/55 stop failing with mismatched metadata errors. Upgrade to 3.1.5 or newer (@clerk/expo CHANGELOG).
OAuth redirect URI mismatch. Clerk's default mobile SSO redirect is {bundleIdentifier}://callback. Verify the Dashboard's "Allowlist for mobile SSO redirect" matches your app's Bundle ID (Deploy an Expo app to production).
White flash on AuthView mount (iOS). Fixed in @clerk/expo 3.1.10.
Native and JavaScript auth state disagree (a sign-in or sign-out isn't reflected in useAuth()). Early 3.x builds could leave the two layers out of step. @clerk/expo hardened two-way sync across versions 3.4.4 through 3.5.0 (cold-start hydration of a JS-owned session into the native views, sign-out propagation from either runtime, and correct handling of native multi-session changes), so upgrade to 3.5.0 or newer if you see persistent disagreement (@clerk/expo CHANGELOG).
useAuth() reports signed-out briefly after a successful native sign-in. This is the native-session → JS-Clerk sync window. clerk-ios has already created the session (as pending); @clerk/expo is still syncing it into the React tree. Since useAuth() defaults treatPendingAsSignedOut: true, the hook reads pending as signed-out. Set useAuth({ treatPendingAsSignedOut: false }) anywhere you watch isSignedIn after a native flow to bridge the gap.
Conclusion
You now have a fully functional Expo application using Clerk's native SwiftUI components. You have implemented Google and Apple Sign-In, added user management with <UserButton /> and <UserProfileView />, protected your routes, and explored advanced topics like the Core 3 Signal API, theming, and offline session rehydration.
Frequently asked questions
Next steps
- Add passkeys. Install
@clerk/expo-passkeysand pass__experimental_passkeysto<ClerkProvider>to enable passkey sign-in and enrollment inside<AuthView />and<UserProfileView />. Works on iOS 16+ and Android 9+ (Clerk Expo passkeys). - Biometric sign-in. Use
useLocalCredentials()to authenticate with a stored password via Face ID or Touch ID on return visits. It's a password-strategy-only hook, so it complements<AuthView />rather than replacing it (ClerkuseLocalCredentials). - Authenticated backend calls. Use
getToken()fromuseAuth()to retrieve a short-lived session JWT and send it as anAuthorization: Bearerheader to your own backend (Clerk session tokens).
In this series
- How to use SwiftUI components in a React Expo and Clerk app
- How to use SwiftUI components in a React Expo and Clerk app - Part 2 (you are here)