Expo Router makes navigation feel like Next.js for mobile. Here's how to build a proper authentication flow with protected routes.
Project Structure
app/
├── _layout.tsx # Root layout with auth provider
├── (auth)/
│ ├── _layout.tsx # Auth group layout
│ ├── login.tsx
│ └── register.tsx
├── (app)/
│ ├── _layout.tsx # Protected group layout
│ ├── index.tsx # Home screen
│ └── profile.tsx
└── index.tsx # Entry redirect
Step 1: Create Auth Context
// context/auth.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
type AuthContextType = {
user: User | null;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
isLoading: boolean;
};
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadUser();
}, []);
async function loadUser() {
try {
const stored = await AsyncStorage.getItem('user');
if (stored) {
setUser(JSON.parse(stored));
}
} finally {
setIsLoading(false);
}
}
async function signIn(email: string, password: string) {
// Call your API
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const user = await response.json();
await AsyncStorage.setItem('user', JSON.stringify(user));
setUser(user);
}
async function signOut() {
await AsyncStorage.removeItem('user');
setUser(null);
}
return (
<AuthContext.Provider value={{ user, signIn, signOut, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};Step 2: Root Layout
// app/_layout.tsx
import { Slot } from 'expo-router';
import { AuthProvider } from '../context/auth';
export default function RootLayout() {
return (
<AuthProvider>
<Slot />
</AuthProvider>
);
}Step 3: Entry Point with Redirect
// app/index.tsx
import { Redirect } from 'expo-router';
import { useAuth } from '../context/auth';
import { View, ActivityIndicator } from 'react-native';
export default function Index() {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return <Redirect href={user ? '/(app)' : '/(auth)/login'} />;
}Step 4: Protected Layout
// app/(app)/_layout.tsx
import { Redirect, Stack } from 'expo-router';
import { useAuth } from '../../context/auth';
export default function AppLayout() {
const { user } = useAuth();
// Redirect to login if not authenticated
if (!user) {
return <Redirect href="/(auth)/login" />;
}
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Home' }} />
<Stack.Screen name="profile" options={{ title: 'Profile' }} />
</Stack>
);
}Step 5: Auth Layout
// app/(auth)/_layout.tsx
import { Redirect, Stack } from 'expo-router';
import { useAuth } from '../../context/auth';
export default function AuthLayout() {
const { user } = useAuth();
// Redirect to app if already logged in
if (user) {
return <Redirect href="/(app)" />;
}
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
</Stack>
);
}Step 6: Login Screen
// app/(auth)/login.tsx
import { useState } from 'react';
import { View, TextInput, Button, StyleSheet, Text } from 'react-native';
import { Link } from 'expo-router';
import { useAuth } from '../../context/auth';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { signIn } = useAuth();
async function handleLogin() {
try {
setError('');
await signIn(email, password);
} catch (e) {
setError('Invalid credentials');
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome Back</Text>
{error && <Text style={styles.error}>{error}</Text>}
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Button title="Sign In" onPress={handleLogin} />
<Link href="/(auth)/register" style={styles.link}>
Don't have an account? Sign up
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, justifyContent: 'center' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
input: { borderWidth: 1, borderColor: '#ccc', padding: 12, marginBottom: 12, borderRadius: 8 },
error: { color: 'red', marginBottom: 12 },
link: { marginTop: 20, color: 'blue', textAlign: 'center' },
});Step 7: Protected Home Screen
// app/(app)/index.tsx
import { View, Text, Button } from 'react-native';
import { useAuth } from '../../context/auth';
export default function Home() {
const { user, signOut } = useAuth();
return (
<View style={{ flex: 1, padding: 20 }}>
<Text style={{ fontSize: 20 }}>Welcome, {user?.name}!</Text>
<Button title="Sign Out" onPress={signOut} />
</View>
);
}Key Points
- Group routes with
(parentheses)don't affect the URL - Layout files in each group handle the auth check
- Redirect component handles navigation based on auth state
- AsyncStorage persists the session across app restarts
This pattern keeps auth logic clean and routes protected automatically.