
How to Protect Routes in Expo Router with Clerk
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 a complete Expo Router app with Clerk authentication, covering public and private routes, role-based access control, feature-based authorization, deep linking, and the most common pitfalls that trip up mobile developers.
What you'll build
A complete Expo Router application with:
- Sign-up and sign-in screens using Clerk's Core 3 API
- A protected dashboard and user profile
- An admin-only section with role-based access control
- Tab navigation with conditional route visibility
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 tab
└── admin/
├── _layout.tsx # Role-based guard (admin only)
└── index.tsx # Admin dashboardExpo 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 in the Clerk Dashboard under API Keys. You also need to enable Native API in the Clerk Dashboard, a commonly missed step.
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 dashboard, profile, admin, and all 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.Screen name="admin" options={{ title: 'Admin' }} />
</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('https://your-api.com/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.
Admin dashboard
The admin dashboard is only accessible to users with the admin role. The layout guard (covered in the next section) handles the access control.
app/(app)/admin/index.tsx
import { useAuth, useUser } from '@clerk/expo'
import { View, Text, StyleSheet } from 'react-native'
export default function AdminDashboardScreen() {
const { sessionClaims } = useAuth()
const { user } = useUser()
const role = sessionClaims?.metadata?.role
return (
<View style={styles.container}>
<Text style={styles.title}>Admin Dashboard</Text>
<Text style={styles.detail}>Signed in as {user?.primaryEmailAddress?.emailAddress}</Text>
<Text style={styles.detail}>Role: {role}</Text>
<Text style={styles.info}>
This screen is protected by the admin layout guard. Only users with the admin role in their
publicMetadata can reach this screen.
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24 },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 16 },
detail: { fontSize: 14, color: '#666', marginBottom: 8 },
info: { fontSize: 14, color: '#333', marginTop: 16, lineHeight: 22 },
})The sessionClaims object gives you access to the custom claims you configured in the Clerk Dashboard. Since you added publicMetadata to the session token, the role is available at sessionClaims?.metadata?.role without any additional API calls.
Role-based access control (RBAC)
Setting up roles in Clerk
For apps that don't use Clerk Organizations, the recommended approach is storing roles in publicMetadata on the user object.
Clerk has three metadata types on the user object:
publicMetadata is the right choice for roles because it's readable from the frontend (so your layout guards can check it) but only writable from a backend API (so users can't escalate their own privileges).
Set a user's role using the Clerk backend SDK:
app/api/set-role+api.ts
import { clerkClient } from '@clerk/express'
export async function POST(request: Request) {
const { userId, role } = await request.json()
await clerkClient().users.updateUserMetadata(userId, {
publicMetadata: { role },
})
return Response.json({ success: true })
}The same pattern works with any Node.js backend (Express, Hono, Fastify, etc.), not just Expo Router API routes.
Next, customize the session token to include role data. In the Clerk Dashboard, go to Sessions and click Edit on the claims editor. Add:
{
"metadata": "{{user.public_metadata}}"
}This makes the role available in the session token, so you can read it client-side without a separate API call. Custom claims are limited to about 1.2KB (constrained by the 4KB cookie size limit after Clerk's default claims).
TypeScript type definitions
Make the custom claims type-safe by adding a global type declaration:
types/globals.d.ts
export {}
type Roles = 'admin' | 'moderator' | 'user'
declare global {
interface CustomJwtSessionClaims {
metadata?: {
role?: Roles
}
}
}Reading roles and protecting routes by role
Read the role from sessionClaims via useAuth():
utils/roles.ts
import { useAuth } from '@clerk/expo'
export function useRole() {
const { sessionClaims } = useAuth()
return sessionClaims?.metadata?.role
}
export function useIsAdmin() {
return useRole() === 'admin'
}Protect admin routes with a layout guard:
app/(app)/admin/_layout.tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
export default function AdminLayout() {
const { isLoaded, sessionClaims } = useAuth()
const role = sessionClaims?.metadata?.role
if (!isLoaded) return null
if (role !== 'admin') {
return <Redirect href="/(app)" />
}
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Admin Dashboard' }} />
</Stack>
)
}With Stack.Protected, you can set the guard at the parent layout level instead:
// In app/(app)/_layout.tsx
;<Stack.Protected guard={role === 'admin'}>
<Stack.Screen name="admin" />
</Stack.Protected>Server-side validation is essential. Client-side role checks are for UX, not security. Always verify roles on your backend before granting access to sensitive data or operations.
Feature-based access with Organizations
For B2B multi-tenant apps, Clerk Organizations provide built-in RBAC with the has() function, custom roles, and custom permissions.
import { Show } from '@clerk/expo'
import { View } from 'react-native'
export default function RootLayout() {
return (
<View style={styles.container}>
// Permission-based UI
<Show when={{ permission: 'org:posts:edit' }}>
<EditButton />
</Show>
// Plan-based UI
<Show when={{ plan: 'premium' }}>
<PremiumFeatures />
</Show>
// Feature-based UI
<Show when={{ feature: 'premium_access' }}>
<AdvancedAnalytics />
</Show>
</View>
)
}The has() function supports 4 parameter shapes: role (org-scoped), permission (org-scoped), feature (user or org-scoped), and plan (user or org-scoped). Plans and features work at the user level too, meaning B2C apps without Organizations can use has({ plan: 'premium' }) and has({ feature: 'premium_access' }). The org:resource:action namespace convention applies only to roles and permissions, not to plans or features, which use plain strings.
Session tokens have a 60-second default lifetime and refresh automatically before expiry. Role changes propagate on the next token refresh. For immediate updates, use getToken({ skipCache: true }) to force a fresh token, or user.reload() to refresh user data.
Conditional UI based on roles
Hide navigation elements based on the user's role. For example, conditionally hide the admin tab for non-admin users (full tab layout implementation in the next section):
<Tabs.Screen
name="admin"
options={{
title: 'Admin',
href: role === 'admin' ? '/(app)/admin' : null,
}}
/>Setting href to null hides the tab from the navigation bar while keeping the route defined. Non-admin users won't see the tab, and the admin layout guard catches any direct access attempts.
Tab navigators with protected routes
Setting up protected tabs
Here's the full (app) layout with tabs and a conditional admin tab:
app/(app)/_layout.tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
export default function AppLayout() {
const { isSignedIn, isLoaded, sessionClaims } = useAuth()
const role = sessionClaims?.metadata?.role
if (!isLoaded) return null
// Only needed if NOT using Stack.Protected in root layout
if (!isSignedIn) {
return <Redirect href="/(auth)/sign-in" />
}
return (
<Tabs screenOptions={{ headerShown: true }}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => <Ionicons name="person" color={color} size={size} />,
}}
/>
<Tabs.Screen
name="admin"
options={{
title: 'Admin',
href: role === 'admin' ? '/(app)/admin' : null,
tabBarIcon: ({ color, size }) => <Ionicons name="shield" color={color} size={size} />,
}}
/>
</Tabs>
)
}When href is null, the tab disappears from the tab bar. When the user's role changes (for instance, they're promoted to admin), the tab appears on the next render.
Nested stack navigation within tabs
Each tab can contain its own Stack navigator for drill-down navigation. Navigation state persists when switching between tabs.
app/(app)/
├── _layout.tsx # Tabs navigator
├── index.tsx # Home tab root
├── details/
│ ├── _layout.tsx # Stack inside Home tab
│ └── [id].tsx # Detail screen
├── profile.tsx # Profile tab root
└── admin/
├── _layout.tsx # Stack + role guard
└── index.tsx # Admin dashboardA user can navigate from the Home tab into a details screen, switch to the Profile tab, switch back to Home, and find their details screen still on the stack.
Handling deep links to protected routes
How deep linking works with route protection
Expo Router provides built-in deep linking. Every file in the app/ directory is automatically deep linkable. A link like myauthapp://profile opens the profile screen directly. A link like myauthapp://admin opens the admin section (if the user has access).
When an unauthenticated user taps a deep link to a protected route, the auth guard in the layout redirects them to sign-in. The catch: Expo Router does not automatically redirect back to the deep-linked route after authentication. You need to capture the intended destination and handle the redirect yourself.
Implementing post-authentication redirect
Capture the intended URL before redirecting to sign-in, then navigate there after successful authentication:
app/(auth)/sign-in.tsx (with deep link support)
import { useSignIn } from '@clerk/expo'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from 'react-native'
export default function SignInScreen() {
const { signIn, errors, fetchStatus } = useSignIn()
const router = useRouter()
const { returnTo } = useLocalSearchParams<{ returnTo?: string }>()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSignIn = async () => {
await signIn.password({ identifier: email, password })
await signIn.finalize({
navigate: () => {
router.replace(returnTo || '/(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>
)}
<TouchableOpacity style={styles.button} onPress={handleSignIn}>
<Text style={styles.buttonText}>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 },
})Pass the intended destination as a query parameter when redirecting from the auth guard:
// In the (app) layout guard (alternative approach):
const pathname = usePathname()
if (!isSignedIn) {
return <Redirect href={`/(auth)/sign-in?returnTo=${pathname}`} />
}Configuring custom URL schemes
Configure your app's deep link scheme in app.json:
{
"expo": {
"scheme": "myauthapp"
}
}This enables links like myauthapp://dashboard to open your app directly. For production apps, configure Universal Links (iOS) and App Links (Android) for https:// scheme links that work even when the app isn't installed.
OAuth callback redirects use expo-web-browser to open the auth provider in an in-app browser and return to the app via the configured scheme.
An alternative pattern for deep links with Stack.Protected: present sign-in as a modal. When a deep link opens a protected screen, the background route is preserved behind the modal sign-in screen. After authentication, dismiss the modal and the user sees the originally linked content without any redirect logic. This works with Expo Router's modal presentation options (presentation: 'modal' or presentation: 'formSheet' on a Stack.Screen).
Managing authentication state during app startup
The startup timing problem
On cold start, the app needs to restore the session token from secure storage before it can determine if the user is signed in. This takes a few hundred milliseconds. Without proper handling, users see a flash of the sign-in screen before being redirected to the dashboard, or the dashboard briefly appears before redirecting to sign-in.
The complete root layout
Here's the full root layout bringing together everything from the previous sections: ClerkProvider, tokenCache, SplashScreen, and Stack.Protected.
app/_layout.tsx (final version)
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'
// Must be called at module scope (calling inside a component may be too late)
SplashScreen.preventAutoHideAsync()
function RootNavigator() {
const { isSignedIn, isLoaded } = useAuth()
if (!isLoaded) {
// Keep the splash screen visible while Clerk restores the session
return null
}
// Auth state is resolved; safe to dismiss the splash screen
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>
)
}By returning null from RootNavigator while isLoaded is false, the splash screen stays visible. Once Clerk restores the session, isLoaded flips to true, the navigator renders with the correct guards, and hideAsync() dismisses the splash screen. The user never sees the wrong screen.
Token persistence and offline support
Clerk's tokenCache handles persistence automatically. Session tokens are encrypted and stored on-device (iOS Keychain, Android Keystore). On restart, Clerk restores the session without requiring the user to sign in again.
For offline support, import resourceCache and pass it as an experimental prop:
import { resourceCache } from '@clerk/expo/resource-cache'
;<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
tokenCache={tokenCache}
__experimental_resourceCache={resourceCache}
>
<RootNavigator />
</ClerkProvider>When enabled, resourceCache persists three categories of data to secure storage:
- Environment configuration: authentication strategies, display settings, organization settings, and feature flags for your Clerk instance
- Client state: active sessions, user data (email addresses, phone numbers, external accounts), and current sign-in/sign-up state
- Session JWT: the last active session token, returned by
getToken()when the network is unavailable
This means the app can render user information, check roles, and make authenticated API requests (using the cached JWT) even when offline. The cached JWT may be expired, so your backend should handle token expiry gracefully.
The __experimental_ prefix is on the prop name only; the import path (@clerk/expo/resource-cache) is stable. The resource cache is available on iOS and Android only (not Expo Web). When resourceCache is enabled, Clerk automatically surfaces network errors via isClerkRuntimeError() with err.code === 'network_error' instead of silently swallowing them, enabling custom offline error handling.
Clerk's session tokens have a 60-second default lifetime and refresh automatically approximately every 50 seconds. This happens in the background with no action needed from your code. If a token refresh fails (for example, during a network outage) and resourceCache is enabled, getToken() returns the cached token. Without resourceCache, a failed refresh causes isSignedIn to eventually flip to false when the token expires, triggering the route guards.
The session itself (not the token) has a configurable lifetime defaulting to 7 days with a rolling inactivity timeout. As long as the user opens the app within that window, they stay signed in.
Common mistakes and gotchas
1. Redirecting before auth state loads
Checking isSignedIn without first checking isLoaded causes premature redirects. On app startup, isSignedIn is undefined until Clerk restores the session.
// ✅ Correct: gate on isLoaded first
const { isLoaded, isSignedIn } = useAuth()
if (!isLoaded) return null
if (!isSignedIn) return <Redirect href="/(auth)/sign-in" />Never skip the isLoaded check. Without it, every cold start redirects to sign-in, even for authenticated users. This is the single most common bug in Expo Router auth implementations.
2. Using hooks outside ClerkProvider
useAuth(), useUser(), useSignIn(), and all other Clerk hooks must be called inside a component wrapped by ClerkProvider. If you call them outside the provider, you'll get a runtime error about missing context. Place ClerkProvider in the root layout so all routes have access.
// ✅ Correct: hooks called in a child of ClerkProvider
export default function RootLayout() {
return (
<ClerkProvider publishableKey={key} tokenCache={tokenCache}>
<RootNavigator /> {/* useAuth() is safe here */}
</ClerkProvider>
)
}3. Flash of wrong screen
Rendering route content before auth state resolves causes a visible flash. Return null or a loading indicator while isLoaded is false.
// ✅ Correct: show nothing until auth is resolved
if (!isLoaded) return nullPaired with SplashScreen.preventAutoHideAsync(), this keeps the splash screen visible until the correct route is determined.
4. Navigation stack pollution after sign-out
After signing out, the user can press back and return to protected screens if the navigation stack isn't cleaned up.
// ✅ Correct: use replace for auth transitions
router.replace('/(auth)/sign-in')With Stack.Protected, this is handled automatically. When isSignedIn changes, the guard removes protected screens from the history.
5. Expo Go limitations
Not everything works in Expo Go. Features that require a development build:
- Native OAuth (Google Sign-In, Apple Sign-In)
- Native Clerk components (
AuthView,UserButton,UserProfileView) - Passkeys
- API routes (
+api.tsfiles)
JavaScript-only sign-in/sign-up flows and browser-based OAuth work in Expo Go. Plan your development environment around the features you need.
6. Rendering views before Slot in the root layout
Never conditionally render content before <Slot /> or <Stack> in the root layout. This prevents the navigator from mounting and causes a "Navigation object not initialized" runtime error.
// ❌ Wrong: rendering before the navigator
export default function RootLayout() {
const { isLoaded } = useAuth()
if (!isLoaded) return <LoadingScreen /> // blocks Slot from mounting
return <Slot />
}
// ✅ Correct: move auth logic to a child component
export default function RootLayout() {
return (
<ClerkProvider publishableKey={key} tokenCache={tokenCache}>
<RootNavigator /> {/* auth checks happen here */}
</ClerkProvider>
)
}7. Conditional hook calls
React hooks can't be called conditionally. This applies to useAuth(), useUser(), and all Clerk hooks.
// ❌ Wrong: conditional hook call
if (showProfile) {
const { user } = useUser()
}
// ✅ Correct: always call the hook, use the value conditionally
const { user } = useUser()
if (showProfile && user) {
// render profile
}Testing auth flows
For component-level testing of route protection and navigation, use expo-router/testing-library (provides renderRouter extending @testing-library/react-native). For mobile E2E testing of full auth flows, Maestro is the recommended tool. Note that @clerk/testing is designed for web E2E testing only (Playwright/Cypress) and doesn't support React Native or Expo.