Skip to main content

Navigation & Routing

Navigation is a fundamental aspect of mobile app development. This guide covers Expo Router, which provides file-based routing similar to Next.js, and traditional React Navigation patterns.

Expo Router Overview

Expo Router uses the file system to define routes, making navigation intuitive and type-safe.

Basic File Structure

app/
├── _layout.tsx # Root layout
├── index.tsx # Home screen (/)
├── about.tsx # About screen (/about)
├── (tabs)/ # Tab group
│ ├── _layout.tsx # Tab layout
│ ├── index.tsx # Home tab
│ └── profile.tsx # Profile tab
├── modal.tsx # Modal screen
├── user/
│ ├── [id].tsx # Dynamic route (/user/123)
│ └── settings.tsx # Nested route (/user/settings)
└── +not-found.tsx # 404 page

Root Layout

// app/_layout.tsx
import { Stack } from "expo-router"
import { ThemeProvider } from "@/contexts/ThemeContext"

export default function RootLayout() {
return (
<ThemeProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{
presentation: "modal",
title: "Modal Screen",
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
</ThemeProvider>
)
}

Tab Navigation

// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useTheme } from "@/contexts/ThemeContext"

export default function TabLayout() {
const { theme } = useTheme()

return (
<Tabs
screenOptions={{
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: theme.colors.textSecondary,
tabBarStyle: {
backgroundColor: theme.colors.background,
},
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTintColor: theme.colors.text,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "home" : "home-outline"} size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: "Explore",
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "compass" : "compass-outline"} size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "person" : "person-outline"} size={24} color={color} />
),
}}
/>
</Tabs>
)
}

Programmatic Navigation

import { router, useRouter } from "expo-router"

function NavigationExamples() {
const router = useRouter()

const navigateToProfile = () => {
// Navigate to a route
router.push("/profile")

// Navigate with parameters
router.push({
pathname: "/user/[id]",
params: { id: "123" },
})

// Replace current route
router.replace("/login")

// Navigate back
router.back()

// Navigate to root
router.dismissAll()
}

return (
<View>
<Pressable onPress={navigateToProfile}>
<Text>Go to Profile</Text>
</Pressable>
</View>
)
}

Dynamic Routes

// app/user/[id].tsx
import { useLocalSearchParams } from "expo-router"
import { useEffect, useState } from "react"

interface User {
id: string
name: string
email: string
}

export default function UserProfile() {
const { id } = useLocalSearchParams<{ id: string }>()
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)

useEffect(() => {
async function fetchUser() {
try {
const response = await fetch(`/api/users/${id}`)
const userData = await response.json()
setUser(userData)
} catch (error) {
console.error("Failed to fetch user:", error)
} finally {
setLoading(false)
}
}

if (id) {
fetchUser()
}
}, [id])

if (loading) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" />
</View>
)
}

if (!user) {
return (
<View style={styles.container}>
<Text>User not found</Text>
</View>
)
}

return (
<View style={styles.container}>
<Text style={styles.name}>{user.name}</Text>
<Text style={styles.email}>{user.email}</Text>
</View>
)
}

Route Groups and Layouts

// app/(auth)/_layout.tsx - Auth-specific layout
import { Stack } from "expo-router"
import { Redirect } from "expo-router"
import { useAuth } from "@/contexts/AuthContext"

export default function AuthLayout() {
const { user } = useAuth()

// Redirect if already authenticated
if (user) {
return <Redirect href="/(tabs)" />
}

return (
<Stack>
<Stack.Screen
name="login"
options={{
title: "Sign In",
headerShown: false,
}}
/>
<Stack.Screen
name="register"
options={{
title: "Create Account",
headerBackTitle: "Back",
}}
/>
<Stack.Screen
name="forgot-password"
options={{
title: "Reset Password",
presentation: "modal",
}}
/>
</Stack>
)
}

Advanced Navigation Patterns

Protected Routes

// components/ProtectedRoute.tsx
import { useAuth } from "@/contexts/AuthContext"
import { Redirect } from "expo-router"
import { ReactNode } from "react"

interface ProtectedRouteProps {
children: ReactNode
redirectTo?: string
}

export function ProtectedRoute({ children, redirectTo = "/(auth)/login" }: ProtectedRouteProps) {
const { user, loading } = useAuth()

if (loading) {
return <LoadingScreen />
}

if (!user) {
return <Redirect href={redirectTo} />
}

return <>{children}</>
}

// Usage in layout
export default function ProtectedLayout() {
return (
<ProtectedRoute>
<Stack>
<Stack.Screen name="dashboard" />
<Stack.Screen name="settings" />
</Stack>
</ProtectedRoute>
)
}

Deep Linking

// app.json configuration
{
"expo": {
"scheme": "myapp",
"web": {
"bundler": "metro"
}
}
}

// Handle deep links in component
import { useEffect } from 'react';
import { Linking } from 'react-native';
import { router } from 'expo-router';

export function useDeepLinking() {
useEffect(() => {
// Handle app launch from deep link
const handleInitialURL = async () => {
const initialURL = await Linking.getInitialURL();
if (initialURL) {
handleDeepLink(initialURL);
}
};

// Handle deep links when app is already running
const subscription = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url);
});

handleInitialURL();

return () => subscription?.remove();
}, []);

const handleDeepLink = (url: string) => {
// Parse URL and navigate accordingly
// myapp://user/123 -> /user/123
const route = url.replace('myapp://', '/');
router.push(route);
};
}
// app/modal.tsx
import { StatusBar } from "expo-status-bar"
import { Platform } from "react-native"
import { router } from "expo-router"

export default function ModalScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Modal Screen</Text>

<Pressable style={styles.closeButton} onPress={() => router.back()}>
<Text style={styles.closeText}>Close</Text>
</Pressable>

{/* Use correct status bar for modal */}
<StatusBar style={Platform.OS === "ios" ? "light" : "auto"} />
</View>
)
}

// Present modal programmatically
function PresentModal() {
const showModal = () => {
router.push("/modal")
}

return (
<Pressable onPress={showModal}>
<Text>Show Modal</Text>
</Pressable>
)
}

Drawer Navigation

// app/(drawer)/_layout.tsx
import { Drawer } from "expo-router/drawer"
import { Ionicons } from "@expo/vector-icons"

export default function DrawerLayout() {
return (
<Drawer>
<Drawer.Screen
name="index"
options={{
drawerLabel: "Home",
title: "Home",
drawerIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
}}
/>
<Drawer.Screen
name="settings"
options={{
drawerLabel: "Settings",
title: "Settings",
drawerIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
}}
/>
</Drawer>
)
}
// contexts/NavigationContext.tsx
import { createContext, useContext, useEffect, useState } from "react"
import AsyncStorage from "@react-native-async-storage/async-storage"

const NAVIGATION_STATE_KEY = "NAVIGATION_STATE"

interface NavigationContextType {
isReady: boolean
initialState: any
}

const NavigationContext = createContext<NavigationContextType>({
isReady: false,
initialState: undefined,
})

export function NavigationProvider({ children }: { children: React.ReactNode }) {
const [isReady, setIsReady] = useState(false)
const [initialState, setInitialState] = useState()

useEffect(() => {
const restoreState = async () => {
try {
const savedState = await AsyncStorage.getItem(NAVIGATION_STATE_KEY)
const state = savedState ? JSON.parse(savedState) : undefined
setInitialState(state)
} catch (error) {
console.warn("Failed to restore navigation state:", error)
} finally {
setIsReady(true)
}
}

restoreState()
}, [])

return <NavigationContext.Provider value={{ isReady, initialState }}>{children}</NavigationContext.Provider>
}

Custom Navigation Hook

// hooks/useNavigation.ts
import { useRouter, useSegments } from "expo-router"
import { useAuth } from "@/contexts/AuthContext"
import { useEffect } from "react"

export function useNavigationAuth() {
const { user, loading } = useAuth()
const segments = useSegments()
const router = useRouter()

useEffect(() => {
if (loading) return

const inAuthGroup = segments[0] === "(auth)"

if (!user && !inAuthGroup) {
// Redirect to login if not authenticated
router.replace("/(auth)/login")
} else if (user && inAuthGroup) {
// Redirect to main app if authenticated
router.replace("/(tabs)")
}
}, [user, segments, loading])
}

Type-Safe Navigation

Route Parameter Types

// types/navigation.ts
export type RootStackParamList = {
"(tabs)": undefined
"(auth)": undefined
modal: { userId?: string }
"user/[id]": { id: string }
"+not-found": undefined
}

export type TabParamList = {
index: undefined
explore: { category?: string }
profile: { userId?: string }
}

// Generate typed navigation hooks
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}

Typed Navigation Functions

// utils/navigation.ts
import { router } from "expo-router"
import { RootStackParamList } from "@/types/navigation"

type RouteName = keyof RootStackParamList

export const navigate = <T extends RouteName>(route: T, params?: RootStackParamList[T]) => {
if (params) {
router.push({ pathname: route, params })
} else {
router.push(route)
}
}

// Usage with type safety
navigate("user/[id]", { id: "123" }) // ✅ Type safe
navigate("modal", { userId: "456" }) // ✅ Type safe
// navigate('modal', { wrongParam: 'test' }); // ❌ Type error

Performance Optimization

Lazy Loading Screens

// Use dynamic imports for large screens
import { lazy, Suspense } from "react"

const HeavyScreen = lazy(() => import("@/screens/HeavyScreen"))

export default function OptimizedLayout() {
return (
<Stack>
<Stack.Screen
name="heavy"
component={() => (
<Suspense fallback={<LoadingScreen />}>
<HeavyScreen />
</Suspense>
)}
/>
</Stack>
)
}
// Optimize header components
const HeaderTitle = memo(({ title }: { title: string }) => <Text style={styles.headerTitle}>{title}</Text>)

// Optimize tab bar icons
const TabBarIcon = memo(({ name, focused, color }: TabBarIconProps) => (
<Ionicons name={focused ? name : (`${name}-outline` as any)} size={24} color={color} />
))

Testing Navigation

// __tests__/navigation.test.tsx
import { render, fireEvent } from "@testing-library/react-native"
import { router } from "expo-router"
import NavigationComponent from "@/components/NavigationComponent"

// Mock router
jest.mock("expo-router", () => ({
router: {
push: jest.fn(),
back: jest.fn(),
replace: jest.fn(),
},
useRouter: () => ({
push: jest.fn(),
back: jest.fn(),
replace: jest.fn(),
}),
}))

describe("Navigation", () => {
it("navigates to profile when button is pressed", () => {
const { getByText } = render(<NavigationComponent />)

fireEvent.press(getByText("Go to Profile"))

expect(router.push).toHaveBeenCalledWith("/profile")
})

it("handles back navigation", () => {
const { getByText } = render(<NavigationComponent />)

fireEvent.press(getByText("Go Back"))

expect(router.back).toHaveBeenCalled()
})
})

Next Steps

Continue your React Native journey with: