
How to Protect Routes in Expo Router with Clerk
This is Part 1 of a two-part series on protecting routes in Expo Router with Clerk. In this part, we cover the core setup, route group architecture, and building essential authentication screens. In Part 2, we explore advanced patterns like role-based access control, tab navigators, and deep linking.
To protect routes in Expo Router with Clerk, split your app/ directory into (auth) and (app) route groups, wrap the root layout in <ClerkProvider> with Clerk's tokenCache, then add a layout-level guard in each group's _layout.tsx that checks isSignedIn from useAuth(). Use <Redirect> to send unauthenticated users to /sign-in and signed-in users away from auth screens. Always wait for isLoaded === true before redirecting to avoid the flash-of-wrong-screen problem.
Expo SDK 53+ also offers Stack.Protected, a declarative alternative that automatically cleans up navigation history when a guard fails. This guide walks through both patterns while building an Expo Router app with Clerk authentication. Part 1 covers the foundation: public and private route groups, the <ClerkProvider> setup, and the core sign-in, sign-up, and protected screens. Part 2 builds on it with role-based access control, feature-based authorization, deep linking, and the most common pitfalls that trip up mobile developers.
What you'll build
In this part, you'll build the foundation of an Expo Router app with:
- Sign-up and sign-in screens using Clerk's Core 3 Signal API
- Route protection with both
Stack.Protectedand theuseAuth()+<Redirect>pattern - A protected dashboard and user profile screen
Part 2 extends this foundation with role-based access control, protected tab navigators, and deep linking.
Tech stack: Expo SDK 53+, Expo Router v5+, @clerk/expo v3+, TypeScript
Prerequisites
- React and React Native fundamentals
- Node.js 18+ (20 LTS recommended)
- An Expo development environment
- A Clerk account (free tier works)
How Expo Router's file-based routing works
Expo Router maps files in the app/ directory to navigation routes. Each file becomes a screen. Layout files (_layout.tsx) define the navigation structure for their directory and all child routes.
Route groups use parentheses to organize routes without affecting URLs. A file at app/(app)/dashboard.tsx produces the URL /dashboard, not /(app)/dashboard. This is the key feature that makes auth-based routing work: you can split your app into (auth) and (app) groups with different navigation rules.
app/
├── _layout.tsx # Root layout: ClerkProvider + route guards
├── (auth)/
│ ├── _layout.tsx # Stack navigator for auth screens
│ ├── sign-in.tsx # Sign-in screen
│ └── sign-up.tsx # Sign-up screen
└── (app)/
├── _layout.tsx # Tab navigator with auth guard
├── index.tsx # Dashboard (Home tab)
└── profile.tsx # User profile tabExpo Router supports Stack navigators for hierarchical push/pop navigation, Tab navigators for top-level sections, and nesting them together. The <Redirect> component handles declarative navigation, while useRouter() gives you programmatic control with router.push(), router.replace(), and router.back().
Setting up Clerk with Expo Router
Install dependencies
Create a new Expo project and install the required packages.
npx create-expo-app@latest my-auth-app
cd my-auth-app
npx expo install @clerk/expo expo-secure-storeConfigure environment variables
Create a .env file in the project root with your Clerk publishable key.
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-hereFind your key on the API keys page in the Clerk Dashboard. You also need to enable the Native API on the Native applications page — a commonly missed step that the native @clerk/expo sign-in flows depend on.
Configure ClerkProvider in the root layout
Add ClerkProvider to app/_layout.tsx. The tokenCache from @clerk/expo/token-cache encrypts and persists session tokens on-device using expo-secure-store (iOS Keychain, Android Keystore). This means authentication state survives app restarts without requiring the user to sign in again.
app/_layout.tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'
// Call at module scope (inside a component risks being too late)
SplashScreen.preventAutoHideAsync()
export default function RootLayout() {
return (
<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
>
<Slot />
</ClerkProvider>
)
}Calling SplashScreen.preventAutoHideAsync() at module scope (outside the component function) is important. Calling it inside the component risks running after the splash screen has already been dismissed.
Handle authentication loading state
Clerk's SDK needs time to restore the session from secure storage. During this window, isLoaded from useAuth() is false, and checking isSignedIn would give unreliable results. You can use the ClerkLoaded and ClerkLoading components as an alternative to checking isLoaded directly.
import { ClerkLoaded, ClerkLoading, ClerkProvider } from '@clerk/expo'
import { ActivityIndicator, View } from 'react-native'
export default function RootLayout() {
return (
<ClerkProvider>
<ClerkLoading>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
</ClerkLoading>
<ClerkLoaded>
<Slot />
</ClerkLoaded>
</ClerkProvider>
)
}Protecting routes: public vs private
Route group architecture
Split your app into two route groups:
(auth)/contains sign-in, sign-up, and other public screens(app)/contains the dashboard, profile, and all other protected screens
Each group has its own _layout.tsx that enforces access rules. The parentheses mean these group names never appear in URLs.
Stack.Protected: the recommended approach
Stack.Protected (available since Expo SDK 53 / Router v5) accepts a boolean guard prop. When guard is false, those screens become inaccessible and the user redirects to the anchor route, the nearest accessible screen. It also automatically cleans up navigation history when a screen becomes protected.
app/_layout.tsx (with route guards)
import { ClerkProvider, useAuth } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Stack } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'
SplashScreen.preventAutoHideAsync()
function RootNavigator() {
const { isSignedIn, isLoaded } = useAuth()
if (!isLoaded) return null
SplashScreen.hideAsync()
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={isSignedIn === true}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={isSignedIn === false}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
</Stack>
)
}
export default function RootLayout() {
return (
<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
>
<RootNavigator />
</ClerkProvider>
)
}When isSignedIn is true, the (app) group is accessible and (auth) is blocked. When isSignedIn is false, the opposite applies. This dual-guard pattern handles both directions: preventing unauthenticated users from reaching protected screens, and preventing authenticated users from seeing login screens.
Alternative: useAuth + Redirect
For more control over redirect behavior, or for projects on older SDK versions, use useAuth() with the <Redirect> component in each group's layout.
In the (app) layout, redirect unauthenticated users to sign-in:
app/(app)/_layout.tsx (alternative approach)
import { useAuth } from '@clerk/expo'
import { Redirect, Tabs } from 'expo-router'
export default function AppLayout() {
const { isSignedIn, isLoaded } = useAuth()
if (!isLoaded) return null
if (!isSignedIn) {
return <Redirect href="/(auth)/sign-in" />
}
return (
<Tabs screenOptions={{ headerShown: true }}>
<Tabs.Screen name="index" options={{ title: 'Home' }} />
<Tabs.Screen name="profile" options={{ title: 'Profile' }} />
</Tabs>
)
}In the (auth) layout, redirect authenticated users to the dashboard:
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="/(app)" />
}
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="sign-in" />
<Stack.Screen name="sign-up" />
</Stack>
)
}The critical detail: always check isLoaded before isSignedIn. Skipping this check causes premature redirects on every cold start.
Here's how the two approaches compare side by side:
Show component for conditional UI
The <Show> component from @clerk/expo conditionally renders UI elements based on authentication or authorization state. It handles rendering within a screen, not route-level protection. Always use layout guards for route protection.
import { Show } from '@clerk/expo'
export default function Header() {
return (
<View>
<Show when="signed-in">
<UserAvatar />
</Show>
<Show when="signed-out">
<SignInButton />
</Show>
</View>
)
}Show also supports authorization checks: when={{ role: 'org:admin' }}, when={{ permission: 'org:posts:edit' }}, when={{ plan: 'premium' }}, and when={{ feature: 'premium_access' }}. Plans and features use plain strings. Roles and permissions require an active Organization with the org: prefix.
The treatPendingAsSignedOut prop controls what happens during pending sessions (when a user has authenticated but hasn't completed required session tasks like selecting an organization). By default it's true, meaning pending users see the signed-out fallback content. Set it to false to show the signed-in content for pending users instead.
Building the authentication screens
All examples use Clerk's Core 3 Signal API. Many existing tutorials online show the legacy signIn.create() / setActive() pattern, which is deprecated. The examples below use the current API with signIn.password() / finalize().
The finalize() method accepts an optional navigate callback that controls where the user goes after authentication completes. Clerk passes { session, decorateUrl } to the callback, where session is the newly created session and decorateUrl handles web-specific token management. In Expo apps, you can ignore these parameters and navigate directly.
Sign-up screen
The sign-up flow has two phases: registration and email verification.
app/(auth)/sign-up.tsx
import { useSignUp } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import {
View,
Text,
TextInput,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
} from 'react-native'
export default function SignUpScreen() {
const { signUp, errors, fetchStatus } = useSignUp()
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [pendingVerification, setPendingVerification] = useState(false)
const handleSignUp = async () => {
await signUp.password({ emailAddress: email, password })
await signUp.verifications.sendEmailCode()
setPendingVerification(true)
}
const handleVerification = async () => {
await signUp.verifications.verifyEmailCode({ code })
await signUp.finalize({ navigate: () => router.replace('/(app)') })
}
if (pendingVerification) {
return (
<View style={styles.container}>
<Text style={styles.title}>Verify your email</Text>
<TextInput
value={code}
onChangeText={setCode}
placeholder="Enter verification code"
keyboardType="number-pad"
style={styles.input}
/>
{errors?.fields?.code && <Text style={styles.error}>{errors.fields.code.message}</Text>}
{fetchStatus === 'fetching' ? (
<ActivityIndicator />
) : (
<TouchableOpacity style={styles.button} onPress={handleVerification}>
<Text style={styles.buttonText}>Verify</Text>
</TouchableOpacity>
)}
</View>
)
}
return (
<View style={styles.container}>
<Text style={styles.title}>Create an account</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
style={styles.input}
/>
{errors?.fields?.emailAddress && (
<Text style={styles.error}>{errors.fields.emailAddress.message}</Text>
)}
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Password"
secureTextEntry
style={styles.input}
/>
{errors?.fields?.password && (
<Text style={styles.error}>{errors.fields.password.message}</Text>
)}
{fetchStatus === 'fetching' ? (
<ActivityIndicator />
) : (
<TouchableOpacity style={styles.button} onPress={handleSignUp}>
<Text style={styles.buttonText}>Sign Up</Text>
</TouchableOpacity>
)}
<TouchableOpacity onPress={() => router.push('/(auth)/sign-in')}>
<Text style={styles.link}>Already have an account? Sign in</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: 'center' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 24 },
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
marginBottom: 12,
fontSize: 16,
},
button: {
backgroundColor: '#6C47FF',
padding: 16,
borderRadius: 8,
alignItems: 'center',
},
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
error: { color: '#ef4444', marginBottom: 8, fontSize: 14 },
link: { color: '#6C47FF', marginTop: 16, textAlign: 'center' },
})The Core 3 API handles errors reactively through the errors.fields object. When signUp.password() encounters validation issues (like an invalid email or weak password), errors.fields updates automatically and the component re-renders with the error messages displayed. No try/catch needed for validation errors.
The fetchStatus value switches between 'idle' and 'fetching', so you can show a loading indicator while the API call is in flight.
Sign-in screen
app/(auth)/sign-in.tsx
import { useSignIn } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import {
View,
Text,
TextInput,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
} from 'react-native'
export default function SignInScreen() {
const { signIn, errors, fetchStatus } = useSignIn()
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSignIn = async () => {
await signIn.password({ identifier: email, password })
await signIn.finalize({ navigate: () => router.replace('/(app)') })
}
return (
<View style={styles.container}>
<Text style={styles.title}>Sign in</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
style={styles.input}
/>
{errors?.fields?.identifier && (
<Text style={styles.error}>{errors.fields.identifier.message}</Text>
)}
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Password"
secureTextEntry
style={styles.input}
/>
{errors?.fields?.password && (
<Text style={styles.error}>{errors.fields.password.message}</Text>
)}
{fetchStatus === 'fetching' ? (
<ActivityIndicator />
) : (
<TouchableOpacity style={styles.button} onPress={handleSignIn}>
<Text style={styles.buttonText}>Sign In</Text>
</TouchableOpacity>
)}
<TouchableOpacity onPress={() => router.push('/(auth)/sign-up')}>
<Text style={styles.link}>Don't have an account? Sign up</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: 'center' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 24 },
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
marginBottom: 12,
fontSize: 16,
},
button: {
backgroundColor: '#6C47FF',
padding: 16,
borderRadius: 8,
alignItems: 'center',
},
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
error: { color: '#ef4444', marginBottom: 8, fontSize: 14 },
link: { color: '#6C47FF', marginTop: 16, textAlign: 'center' },
})Using router.replace() in the navigate callback prevents the sign-in screen from remaining in the navigation stack. The callback receives { session, decorateUrl } from Clerk, but in Expo apps you can navigate directly since decorateUrl is primarily for web cookie management. With Stack.Protected, router.push() is also safe because the guard automatically redirects authenticated users away from auth screens. Without route guards, router.replace() is the safer choice.
Sign-out flow
components/SignOutButton.tsx
import { useClerk } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { TouchableOpacity, Text, StyleSheet } from 'react-native'
export function SignOutButton() {
const { signOut } = useClerk()
const router = useRouter()
const handleSignOut = async () => {
await signOut()
router.replace('/(auth)/sign-in')
}
return (
<TouchableOpacity style={styles.button} onPress={handleSignOut}>
<Text style={styles.text}>Sign Out</Text>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
button: { padding: 12 },
text: { color: '#ef4444', fontSize: 16 },
})With Stack.Protected, signing out triggers the guard change (isSignedIn flips to false), which automatically cleans up navigation history and redirects to the auth screens. The explicit router.replace() call acts as a fallback for setups that don't use Stack.Protected.
OAuth and social sign-in
For social single sign-on, use the useSSO() hook (which replaces the deprecated useOAuth()):
import { useSSO } from '@clerk/expo'
const { startSSOFlow } = useSSO()
const result = await startSSOFlow({ strategy: 'oauth_google' })
// SSO completion uses setActive(), not finalize()
if (result.createdSessionId) {
await result.setActive({ session: result.createdSessionId })
}Native OAuth (Google Sign-In, Apple Sign-In) and native Clerk components (AuthView, UserButton) require a development build. Browser-based OAuth and the JavaScript-only flows shown above work in Expo Go. See the full SSO documentation for complete implementation details.
Building protected screens
Dashboard
The dashboard sits behind the auth guard. Once the user is signed in, they land here.
app/(app)/index.tsx
import { useAuth, useUser } from '@clerk/expo'
import { View, Text, StyleSheet } from 'react-native'
import { SignOutButton } from '../../components/SignOutButton'
export default function DashboardScreen() {
const { userId, sessionId, getToken } = useAuth()
const { user } = useUser()
const fetchProtectedData = async () => {
const token = await getToken()
const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/data`, {
headers: { Authorization: `Bearer ${token}` },
})
return response.json()
}
return (
<View style={styles.container}>
<Text style={styles.title}>
Welcome, {user?.firstName || user?.primaryEmailAddress?.emailAddress}
</Text>
<Text style={styles.detail}>User ID: {userId}</Text>
<Text style={styles.detail}>Session: {sessionId}</Text>
<SignOutButton />
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24 },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 16 },
detail: { fontSize: 14, color: '#666', marginBottom: 8 },
})Use getToken() from useAuth() to attach Clerk session tokens to API requests. Your backend validates these tokens using Clerk's backend SDK to ensure only authenticated users access your API.
User profile
app/(app)/profile.tsx
import { useUser } from '@clerk/expo'
import { View, Text, Image, StyleSheet } from 'react-native'
import { SignOutButton } from '../../components/SignOutButton'
export default function ProfileScreen() {
const { user } = useUser()
return (
<View style={styles.container}>
{user?.imageUrl && <Image source={{ uri: user.imageUrl }} style={styles.avatar} />}
<Text style={styles.name}>{user?.fullName}</Text>
<Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
<SignOutButton />
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, alignItems: 'center' },
avatar: { width: 100, height: 100, borderRadius: 50, marginBottom: 16 },
name: { fontSize: 24, fontWeight: 'bold', marginBottom: 4 },
email: { fontSize: 16, color: '#666', marginBottom: 16 },
})For richer profile management, Clerk provides a native UserProfileView component from @clerk/expo/native. It renders a full profile editing UI using SwiftUI on iOS and Jetpack Compose on Android, but requires a development build.
Next steps
You now have a solid foundation for protecting routes in your Expo Router application using Clerk. You've set up the ClerkProvider, implemented route guards using Stack.Protected, and built functional sign-in, sign-up, and protected dashboard screens.
In Part 2 of this series, we build on this foundation to implement role-based access control (RBAC), secure tab navigators, handle deep links, and manage offline token persistence. We also cover the most common mistakes developers make when implementing authentication in Expo Router.
In this series
- How to Protect Routes in Expo Router with Clerk (you are here)
- How to Protect Routes in Expo Router with Clerk - Part 2