Build an Authentication Flow with Expo Router

April 18, 2024 - 2 min read

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

  1. Group routes with (parentheses) don't affect the URL
  2. Layout files in each group handle the auth check
  3. Redirect component handles navigation based on auth state
  4. AsyncStorage persists the session across app restarts

This pattern keeps auth logic clean and routes protected automatically.

© 2026 Rahul Mandyal. All rights reserved.