
How to Set Up Clerk Authentication with Expo Router: Protected Routes, MFA, and Social Login
This is Part 1 of our guide on setting up Clerk authentication with Expo Router. In this part, you will build the core authentication foundation: scaffolding the Expo project, configuring ClerkProvider and token caching, building the core authentication screens, and protecting routes with route group layouts. Part 2 will cover multi-factor authentication, social logins, and customization.
Setting up authentication in a React Native app with Expo Router requires installing @clerk/expo and expo-secure-store, wrapping your app in ClerkProvider in the root layout, and using useAuth() with <Redirect> in route group layouts to guard authenticated screens. Clerk's Core 3 SDK provides hooks (useSignIn, useSignUp, useAuth) for building custom auth flows, along with native components (AuthView, UserButton) for minimal-code integration.
Multi-factor authentication is handled through the signIn.mfa.* methods after detecting the needs_second_factor status during sign-in. Social login uses useSignInWithGoogle and useSignInWithApple for native platform flows, and useSSO for browser-based providers like GitHub. This guide walks through every step, from project scaffolding to production best practices, with complete code samples for each feature.
For quick setup, see the Clerk Expo quickstart and the Expo Router authentication docs.
Prerequisites
Before starting, confirm you have the following:
- Node.js 20.9.0+ (LTS)
- A Clerk account and a publishable key from the Clerk Dashboard
- Basic familiarity with React and React Native
- Expo CLI (use
npx expocommands directly, no global install required) - A physical device or emulator (native Google/Apple sign-in and native components require a development build; basic email/password works in Expo Go)
- Expo SDK 53 or later (
@clerk/expov3 requires SDK 53+)
What you will build
This two-part guide builds a React Native app with Expo Router and Clerk authentication. In this part, you build the authentication foundation:
- A public homepage (always accessible)
- Sign-in and sign-up screens with email/password
- A two-factor (TOTP) verification step during sign-in
- A protected settings page, guarded by a route group layout
- Sign-out, with loading and redirect handling
Part 2 extends this foundation with a user profile page and UserButton, the full multi-factor authentication (MFA) section (SMS and backup codes), native Google and Apple sign-in, browser-based GitHub sign-in via SSO, and component customization.
The file structure you build in this part looks like this:
app/
_layout.tsx (root layout with ClerkProvider + Slot)
index.tsx (public homepage)
(auth)/
_layout.tsx (auth group layout with useAuth redirect)
sign-in.tsx
sign-up.tsx
(home)/
_layout.tsx (protected group layout with useAuth redirect)
settings.tsxUnderstanding authentication in React Native with Expo Router
The challenge of mobile authentication
Authentication in React Native is more complex than web authentication for several reasons. Mobile apps cannot rely on HTTP-only cookies for session storage. Instead, tokens must be stored in platform-specific secure storage: iOS Keychain Services or Android Keystore-encrypted SharedPreferences. Sessions must persist across app restarts and crashes, which requires careful token lifecycle management.
Native OAuth flows add another layer of complexity. Developers must choose between system browser redirects, in-app webviews, and native SDK integrations. Each approach has different security characteristics and platform requirements. URL schemes, deep linking, App Links (Android), and Universal Links (iOS) all behave differently, and misconfiguration leads to silent failures or security vulnerabilities.
The deprecated auth.expo.io proxy (CVE-2023-28131) illustrates the risks of taking shortcuts with mobile auth. That proxy was widely used for OAuth in Expo apps, but a vulnerability allowed attackers to steal access tokens. Modern implementations must avoid this proxy entirely.
How Expo Router's file-based routing works with authentication
Expo Router uses a file-based routing system where files in the app/ directory become routes automatically. This model maps cleanly to authentication patterns through two key features: route groups and layout routes.
Route groups are directories wrapped in parentheses, like (auth) and (home). They organize routes without affecting URL paths. A file at app/(auth)/sign-in.tsx renders at the /sign-in path, not /(auth)/sign-in. This makes them ideal for separating authenticated and unauthenticated areas of the app.
Layout routes (_layout.tsx) wrap child routes and define navigators (Stack, Tabs, Slot). When a layout file uses useAuth() to check authentication state and renders a <Redirect> component conditionally, it creates a declarative auth guard. All routes within that group inherit the protection logic.
The combination of route groups and layout redirects replaces the need for manual navigation stack manipulation. Every route is also automatically deep-linkable, which means auth guards evaluate on deep link attempts as well.
How Clerk handles authentication in Expo
The @clerk/expo SDK (Core 3) uses a hybrid authentication model. A Client Token (long-lived, stored on the FAPI domain) establishes the device's identity with Clerk. A Session Token (60-second lifetime) authorizes requests to your application's backend. The SDK refreshes the session token every 50 seconds, proactively, before the 60-second expiry. See How Clerk Works for a detailed overview of this architecture.
Token caching uses expo-secure-store through the @clerk/expo/token-cache module. On iOS, tokens are stored in Keychain Services, which persists data across app reinstalls (as long as the bundle ID stays the same). On Android, tokens are stored in SharedPreferences encrypted with Keystore, and this data is deleted on app uninstall.
Clerk offers three integration tiers for Expo:
- JS-only custom UI: Build forms with
useSignIn,useSignUp, and other hooks. Works in Expo Go. Maximum flexibility. - JS + native sign-in: Custom forms combined with native OAuth buttons (
useSignInWithGoogle,useSignInWithApple). Requires a development build. - Native components: Prebuilt
AuthView,UserButton, andUserProfileViewcomponents that render using SwiftUI (iOS) and Jetpack Compose (Android). Requires a development build. Currently in Beta.
The ClerkProvider component wraps the application and manages the session lifecycle, token refresh, and authentication state for all child components.
Setting up the project
Scaffolding a new Expo application
Create a new Expo project with TypeScript:
npx create-expo-app@latest clerk-expo-tutorialThis generates a project on the latest Expo SDK by default. The tutorial works with SDK 53 or later.
Navigate into the project directory:
cd clerk-expo-tutorialInstalling Clerk and dependencies
Install the core packages required for Clerk authentication:
npx expo install @clerk/expo expo-secure-storeFor the full feature set (native OAuth, social login, development builds), install these additional packages:
npx expo install expo-crypto expo-apple-authentication expo-auth-session expo-web-browser expo-dev-clientHere is what each package provides:
@clerk/expo: Clerk's SDK for React Native with Expo, including hooks, components, and session managementexpo-secure-store: Encrypted key-value storage using iOS Keychain and Android Keystoreexpo-crypto: Cryptographic operations required by native Google and Apple sign-inexpo-apple-authentication: Native Apple Sign in with Apple API bindings (iOS only)expo-auth-session: Browser-based OAuth 2.0 flow management for providers like GitHubexpo-web-browser: Opens system browser for authentication (Chrome Custom Tabs on Android, SFSafariViewController on iOS)expo-dev-client: Enables development builds with custom native modules
Configuring environment variables
Create a .env file in the project root with your Clerk publishable key and (optionally) Google OAuth credentials:
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-here
# Google OAuth (required for native Google sign-in)
EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID=your-web-client-id
EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID=your-ios-client-id
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.your-ios-client-id
EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID=your-android-client-idKeys prefixed with pk_test_ are for development instances and enable testing mode. Keys prefixed with pk_live_ are for production instances. Use test keys during development and switch to live keys for production deployments. Add .env to your .gitignore file to prevent committing secrets.
Configuring app.json plugins
Add the required Expo plugins to your app.json:
{
"expo": {
"plugins": [
"expo-secure-store",
[
"@clerk/expo",
{
"appleSignIn": true
}
],
"expo-apple-authentication"
]
}
}The @clerk/expo plugin configures native modules for Clerk. Setting appleSignIn: true adds the Sign in with Apple entitlement to your iOS build. The expo-apple-authentication plugin registers the native Apple Authentication module.
Expo Go vs. development builds
Expo Go is a prebuilt client app for rapid development, but it cannot load custom native modules. A development build is a debug build of your app that includes all custom native code.
For production-quality apps, use development builds. Create one with:
npx expo run:ios --deviceOr use EAS Build for cloud-based builds:
eas build --profile development --platform iosConfiguring ClerkProvider
Adding ClerkProvider to the root layout
The root layout (app/_layout.tsx) wraps the entire application in ClerkProvider. This component must be the outermost wrapper, and it must render <Slot /> as its child to allow Expo Router to render the matched route.
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('EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY is not set.')
}
export default function RootLayout() {
return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<Slot />
</ClerkProvider>
)
}The publishableKey prop must be passed explicitly. Environment variables accessed through process.env are inlined at build time by Metro, so this works in both development and production React Native builds.
Understanding token caching with expo-secure-store
The tokenCache import from @clerk/expo/token-cache wraps expo-secure-store automatically. No custom token cache implementation is needed.
On iOS, tokens are stored in Keychain Services. Keychain data persists across app reinstalls as long as the bundle ID remains the same. This means users can delete and reinstall the app without losing their session.
On Android, tokens are stored in SharedPreferences encrypted with Android Keystore. This data is deleted when the app is uninstalled because the encryption keys are bound to the app's installation. After reinstall, a new session is required.
The expo-secure-store config plugin automatically configures Android Auto Backup exclusion rules (via configureAndroidBackup, which defaults to true). This prevents restored SecureStore data from becoming unreadable after reinstall, since the encryption keys are deleted from Android Keystore on uninstall. If your app uses custom backup rules, set configureAndroidBackup: false in the expo-secure-store plugin config and manually add <exclude domain="sharedpref" path="SecureStore"/> to your backup rules XML.
Handling loading states with ClerkLoaded and ClerkLoading
Clerk needs to initialize before authentication state is available. Use ClerkLoaded and ClerkLoading to control rendering during this period:
import { ClerkProvider, ClerkLoaded, ClerkLoading } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!
export default function RootLayout() {
return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<ClerkLoading>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
</ClerkLoading>
<ClerkLoaded>
<Slot />
</ClerkLoaded>
</ClerkProvider>
)
}ClerkLoaded renders its children only when Clerk's status is 'ready' or 'degraded'. ClerkLoading renders its children while Clerk is still initializing. Use these strategically around Clerk-dependent components rather than wrapping the entire app.
Building the authentication screens
Project structure for authentication routes
The recommended file structure separates public, auth, and protected routes:
app/
_layout.tsx (root layout: ClerkProvider + Slot)
index.tsx (public homepage, always accessible)
(auth)/
_layout.tsx (redirects signed-in users away)
sign-in.tsx
sign-up.tsx
(home)/
_layout.tsx (redirects unauthenticated users to sign-in)
settings.tsxThe (auth) route group contains sign-in and sign-up screens. Its layout checks if the user is already signed in and redirects them to the home area. The (home) route group contains protected screens. Its layout checks if the user is signed in and redirects unauthenticated users to sign-in. Route group names in parentheses do not appear in URL paths.
Creating the sign-up screen
The sign-up screen uses useSignUp() from @clerk/expo with the Core 3 API. The flow has two phases: collecting credentials, then verifying the email address.
import { useSignUp } from '@clerk/expo'
import { Link, useRouter } from 'expo-router'
import type { Href } from 'expo-router'
import { useState } from 'react'
import { Text, TextInput, TouchableOpacity, View } from 'react-native'
export default function SignUpScreen() {
const { signUp, errors, fetchStatus } = useSignUp()
const router = useRouter()
const [emailAddress, setEmailAddress] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [pendingVerification, setPendingVerification] = useState(false)
const onSignUp = async () => {
const { error } = await signUp.password({ emailAddress, password })
if (!error) {
await signUp.verifications.sendEmailCode()
setPendingVerification(true)
}
}
const onVerify = async () => {
await signUp.verifications.verifyEmailCode({ code })
if (signUp.status === 'complete') {
await signUp.finalize()
}
}
if (pendingVerification) {
return (
<View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>
Verify your email
</Text>
<TextInput
value={code}
onChangeText={setCode}
placeholder="Enter verification code"
keyboardType="number-pad"
style={{
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
marginBottom: 16,
}}
/>
{errors?.fields?.code && (
<Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.code[0]?.message}</Text>
)}
<TouchableOpacity
onPress={onVerify}
disabled={fetchStatus === 'fetching'}
style={{
backgroundColor: '#6C47FF',
padding: 14,
borderRadius: 8,
alignItems: 'center',
}}
>
<Text style={{ color: 'white', fontWeight: '600' }}>Verify</Text>
</TouchableOpacity>
</View>
)
}
return (
<View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>Create an account</Text>
<TextInput
value={emailAddress}
onChangeText={setEmailAddress}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
style={{
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
marginBottom: 12,
}}
/>
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Password"
secureTextEntry
style={{
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
marginBottom: 12,
}}
/>
{errors?.fields?.emailAddress && (
<Text style={{ color: 'red', marginBottom: 8 }}>
{errors.fields.emailAddress[0]?.message}
</Text>
)}
{errors?.fields?.password && (
<Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.password[0]?.message}</Text>
)}
<TouchableOpacity
onPress={onSignUp}
disabled={fetchStatus === 'fetching'}
style={{
backgroundColor: '#6C47FF',
padding: 14,
borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
}}
>
<Text style={{ color: 'white', fontWeight: '600' }}>Sign up</Text>
</TouchableOpacity>
<Link href="/(auth)/sign-in" style={{ textAlign: 'center', color: '#6C47FF' }}>
Already have an account? Sign in
</Link>
<View nativeID="clerk-captcha" />
</View>
)
}The <View nativeID="clerk-captcha" /> element at the bottom of the form is used to render the bot-check widget in a custom flow. While recommended for Clerk's bot protection, it is not strictly required; Clerk can fall back to alternative verification methods when the node is missing. The errors.fields object provides field-level error messages from Clerk's validation.
Creating the sign-in screen
The sign-in screen uses useSignIn() with the Core 3 API. After password authentication, the flow checks signIn.status to determine if MFA is required.
import { useSignIn } from '@clerk/expo'
import { Link, useRouter } from 'expo-router'
import type { Href } from 'expo-router'
import { useState } from 'react'
import { Text, TextInput, TouchableOpacity, View } from 'react-native'
export default function SignInScreen() {
const { signIn, errors, fetchStatus } = useSignIn()
const router = useRouter()
const [emailAddress, setEmailAddress] = useState('')
const [password, setPassword] = useState('')
const [mfaRequired, setMfaRequired] = useState(false)
const [mfaCode, setMfaCode] = useState('')
const onSignIn = async () => {
const { error } = await signIn.password({ emailAddress, password })
if (error) return
if (signIn.status === 'complete') {
await signIn.finalize()
} else if (signIn.status === 'needs_second_factor') {
setMfaRequired(true)
}
}
const onVerifyMfa = async () => {
await signIn.mfa.verifyTOTP({ code: mfaCode })
if (signIn.status === 'complete') {
await signIn.finalize()
}
}
if (mfaRequired) {
return (
<View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>
Two-factor authentication
</Text>
<Text style={{ marginBottom: 16, color: '#666' }}>
Enter the code from your authenticator app
</Text>
<TextInput
value={mfaCode}
onChangeText={setMfaCode}
placeholder="Enter MFA code"
keyboardType="number-pad"
style={{
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
marginBottom: 16,
}}
/>
<TouchableOpacity
onPress={onVerifyMfa}
disabled={fetchStatus === 'fetching'}
style={{
backgroundColor: '#6C47FF',
padding: 14,
borderRadius: 8,
alignItems: 'center',
}}
>
<Text style={{ color: 'white', fontWeight: '600' }}>Verify</Text>
</TouchableOpacity>
</View>
)
}
return (
<View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>Sign in</Text>
<TextInput
value={emailAddress}
onChangeText={setEmailAddress}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
style={{
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
marginBottom: 12,
}}
/>
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Password"
secureTextEntry
style={{
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
marginBottom: 12,
}}
/>
{errors?.fields?.identifier && (
<Text style={{ color: 'red', marginBottom: 8 }}>
{errors.fields.identifier[0]?.message}
</Text>
)}
{errors?.fields?.password && (
<Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.password[0]?.message}</Text>
)}
<TouchableOpacity
onPress={onSignIn}
disabled={fetchStatus === 'fetching'}
style={{
backgroundColor: '#6C47FF',
padding: 14,
borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
}}
>
<Text style={{ color: 'white', fontWeight: '600' }}>Sign in</Text>
</TouchableOpacity>
<Link href="/(auth)/sign-up" style={{ textAlign: 'center', color: '#6C47FF' }}>
Don't have an account? Sign up
</Link>
</View>
)
}When signIn.status returns 'needs_second_factor', the component switches to the MFA verification form. This example shows TOTP verification. Part 2 covers SMS and backup code strategies in the full MFA section.
Adding sign-out functionality
Sign-out uses useAuth() from @clerk/expo:
import { useAuth } from '@clerk/expo'
import { TouchableOpacity, Text } from 'react-native'
export function SignOutButton() {
const { signOut } = useAuth()
return (
<TouchableOpacity onPress={() => signOut()} style={{ padding: 8 }}>
<Text style={{ color: '#6C47FF', fontWeight: '600' }}>Sign out</Text>
</TouchableOpacity>
)
}After sign-out, the (home) group layout detects the auth state change via useAuth() and the <Redirect> component sends the user back to the sign-in screen automatically.
Protecting routes in Expo Router
Understanding route groups for auth state
The (auth) group contains screens for unauthenticated users: sign-in and sign-up. The (home) group contains screens for authenticated users — the settings screen in this part, with the user profile page added in Part 2. The root-level index.tsx is public and always accessible. Route groups do not create URL segments, so (auth)/sign-in.tsx renders at /sign-in and (home)/settings.tsx renders at /settings.
Protecting routes with useAuth() redirects in group layouts
Route protection lives in the group layout files, not in the root layout. Each group layout calls useAuth() to check authentication state and renders a <Redirect> component to send users to the appropriate area:
app/(home)/_layout.tsx: If not signed in, redirects to/(auth)/sign-inapp/(auth)/_layout.tsx: If signed in, redirects to/
Both layouts must handle the loading state by returning null (or a loading indicator) while isLoaded is false. This prevents a flash of the wrong content before Clerk finishes initializing.
Building the auth and home group layouts
The auth group layout redirects signed-in users away from the sign-in/sign-up screens:
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="/" />
}
return (
<Stack
screenOptions={{
headerShown: true,
headerTitle: '',
}}
/>
)
}The home group layout protects authenticated screens and adds a sign-out button to the header:
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { SignOutButton } from '../../components/SignOutButton'
export default function HomeLayout() {
const { isSignedIn, isLoaded } = useAuth()
if (!isLoaded) return null
if (!isSignedIn) {
return <Redirect href="/(auth)/sign-in" />
}
return (
<Stack
screenOptions={{
headerRight: () => <SignOutButton />,
}}
/>
)
}The <Redirect> component from expo-router replaces the current route in the navigation stack. When useAuth() detects a state change (sign-in or sign-out), the layout re-renders and the redirect fires. This is client-side only and does not replace server-side auth validation.
Creating the settings page
The settings page is a protected screen inside the (home) group. Because the auth guard lives in the group layout, every screen you add to (home)/ is protected automatically, without additional configuration:
import { useUser } from '@clerk/expo'
import { Text, View, Switch } from 'react-native'
import { useState } from 'react'
export default function SettingsScreen() {
const { user } = useUser()
const [notificationsEnabled, setNotificationsEnabled] = useState(true)
return (
<View style={{ flex: 1, padding: 24 }}>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 24 }}>Settings</Text>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#eee',
}}
>
<Text>Push notifications</Text>
<Switch value={notificationsEnabled} onValueChange={setNotificationsEnabled} />
</View>
<View style={{ paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#eee' }}>
<Text style={{ color: '#666' }}>Account</Text>
<Text style={{ marginTop: 4 }}>{user?.primaryEmailAddress?.emailAddress}</Text>
</View>
<View style={{ paddingVertical: 12 }}>
<Text style={{ color: '#666' }}>Member since</Text>
<Text style={{ marginTop: 4 }}>{user?.createdAt?.toLocaleDateString()}</Text>
</View>
</View>
)
}Any new file added to the app/(home)/ directory — such as the user profile page in Part 2 — is automatically protected by the auth guard in the home group layout.
Creating the public homepage
The public homepage uses Clerk's <Show> component for conditional rendering based on auth state:
import { Show } from '@clerk/expo'
import { Link } from 'expo-router'
import { Text, View } from 'react-native'
export default function HomePage() {
return (
<View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
<Text style={{ fontSize: 28, fontWeight: 'bold', marginBottom: 16 }}>
Welcome to Clerk + Expo Router
</Text>
<Show when="signed-in">
<Text style={{ marginBottom: 16 }}>You are signed in.</Text>
<Link href="/(home)/settings" style={{ color: '#6C47FF', fontSize: 16 }}>
Go to your settings
</Link>
</Show>
<Show when="signed-out">
<Text style={{ marginBottom: 16 }}>Sign in to access your account.</Text>
<Link href="/(auth)/sign-in" style={{ color: '#6C47FF', fontSize: 16 }}>
Sign in
</Link>
</Show>
</View>
)
}In Core 3, the <Show> component replaces the deprecated <SignedIn>, <SignedOut>, and <Protect> components.
Using the Show component for conditional rendering
The <Show> component supports several conditional rendering patterns:
import { Show } from '@clerk/expo'
import { Text } from 'react-native'
function ConditionalExamples() {
return (
<>
{/* Auth state checks */}
<Show when="signed-in">
<Text>Visible to signed-in users</Text>
</Show>
{/* Role-based checks */}
<Show when={{ role: 'org:admin' }}>
<Text>Visible to organization admins</Text>
</Show>
{/* Permission-based checks (recommended over role-based) */}
<Show when={{ permission: 'org:invoices:create' }}>
<Text>Visible to users with invoice creation permission</Text>
</Show>
{/* Custom logic with the has() helper */}
<Show when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}>
<Text>Visible to admins or billing managers</Text>
</Show>
{/* Fallback content */}
<Show when="signed-in" fallback={<Text>Please sign in</Text>}>
<Text>Welcome back</Text>
</Show>
</>
)
}Permission-based checks (when={{ permission: '...' }}) are recommended over role-based checks because they decouple UI logic from role-based access control configuration. Roles can change, but permissions remain stable.
Conclusion
In this first part, you successfully scaffolded an Expo project, configured Clerk's SDK with token caching, and built a robust authentication flow. You set up sign-in and sign-up screens and used Expo Router group layouts to securely protect authenticated routes.
In Part 2, you will extend this application with more advanced authentication patterns, including adding user profiles, multi-factor authentication (MFA), and native social logins for Google and Apple.
FAQ
Does Clerk work natively with Expo Go?
Yes, Clerk works natively with Expo Go using the @clerk/expo SDK. Most basic authentication flows like email/password work out of the box in Expo Go, though advanced features like passkeys or native OAuth may require a development build.
How do I cache authentication tokens in Expo?
Clerk recommends using expo-secure-store to securely cache tokens on the device. This ensures users remain logged in between app restarts.
Why use route groups for protected routes?
Route groups in Expo Router allow you to apply a shared layout to a set of screens without changing the URL structure. This makes it easy to add a layout that checks useAuth() and redirects unauthenticated users away from protected screens.
In this series
- How to Set Up Clerk Authentication with Expo Router: Protected Routes, MFA, and Social Login (you are here)
- How to Set Up Clerk Authentication with Expo Router: Protected Routes, MFA, and Social Login - Part 2