
Introduction
Your navigation stack is three screens deep, the back button goes to the wrong place, and the tab bar disappears. React Native navigation has a learning curve, and this is usually where it hits.
Two libraries exist for React Native navigation. Use React Navigation. The other option, React Native Navigation by Wix, uses truly native navigation controllers and is faster for complex apps — but the setup is painful, the documentation is worse, and the community is smaller. React Navigation is JavaScript-driven (its native stack still uses the platform navigation primitives), well-documented, and fits most apps. If profiling shows navigation is your bottleneck, evaluate the alternative then.
This guide targets React Navigation 7.x with the dynamic, component-based API. Version 7 also has a static configuration API that infers TypeScript types and linking config for you; the concepts below apply to both.
Stack navigation first. Then tabs and drawers where it gets complex. Then deep linking, because nobody sets that up until production forces them to.
Setting Up React Navigation
Modular. Install the core, then only the navigator types you need. No drawer code in your bundle if you only use tabs.
# Core library and dependencies
npm install @react-navigation/native
npm install react-native-screens react-native-safe-area-context
# Navigator types (install the ones you need)
npm install @react-navigation/native-stack
npm install @react-navigation/bottom-tabs
npm install @react-navigation/drawer
# Drawer requires gesture handler and reanimated
npm install react-native-gesture-handler react-native-reanimated
# Used by the examples below: tab icons and token storage
# (@expo/vector-icons ships with Expo; bare apps need expo modules or another icon set)
npm install @expo/vector-icons @react-native-async-storage/async-storage
# In an Expo project, prefer `npx expo install <packages>` so versions match your SDKWrap your app in NavigationContainer. Context provider, must sit at the top:
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ProductStack } from './navigation/ProductStack';
// Start with a single stack. The deep-linking section at the end
// replaces this with the full, auth-aware RootNavigator.
export default function App() {
return (
<SafeAreaProvider>
<NavigationContainer>
<ProductStack />
</NavigationContainer>
</SafeAreaProvider>
);
}SafeAreaProvider handles notch insets. Only ever have one NavigationContainer — wrapping a modal or sub-feature in a second container breaks navigation in confusing ways. Nest navigators, not containers.
Bare project? cd ios && pod install after installing native dependencies. On Android, a couple of lines in MainActivity for react-native-screens. Check the official docs for your React Native version since the exact setup keeps changing.
The examples share a few placeholder screens. Put them in one file so the navigators below have something to render:
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
// Minimal placeholder screens so every example in this guide compiles.
// Replace them with your real screens.
function placeholder(title: string) {
return function PlaceholderScreen() {
return (
<View style={styles.center}>
<Text style={styles.title}>{title}</Text>
</View>
);
};
}
export const HomeScreen = placeholder('Home Feed');
export const DetailScreen = placeholder('Detail');
export const SearchScreen = placeholder('Search');
export const NotificationsScreen = placeholder('Notifications');
export const ProfileScreen = placeholder('Profile');
export const SettingsScreen = placeholder('Settings');
export const HelpScreen = placeholder('Help & Support');
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24 },
});Stack Navigator: Screen-to-Screen Flow
Push on. Pop off. You know the pattern.
Two versions exist: @react-navigation/stack (JavaScript animations) and @react-navigation/native-stack (native platform animations). Use native-stack. Wraps the actual iOS UINavigationController and Android Fragment system. Smoother, less memory. The JS stack only matters if you need custom transition animations the native primitives can't handle. Rarely.
import React from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
// Define the param types for each screen
type ProductStackParams = {
ProductList: undefined;
ProductDetail: { productId: string; title: string };
};
const Stack = createNativeStackNavigator<ProductStackParams>();
// Screen component: Product List
function ProductListScreen({
navigation,
}: NativeStackScreenProps<ProductStackParams, 'ProductList'>) {
const products = [
{ id: '1', title: 'Wireless Headphones', price: '$79.99' },
{ id: '2', title: 'Mechanical Keyboard', price: '$149.99' },
{ id: '3', title: 'USB-C Hub', price: '$49.99' },
];
return (
<FlatList
data={products}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.card}
onPress={() =>
navigation.navigate('ProductDetail', {
productId: item.id,
title: item.title,
})
}
>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.price}>{item.price}</Text>
</TouchableOpacity>
)}
/>
);
}
// Screen component: Product Detail
function ProductDetailScreen({
route,
navigation,
}: NativeStackScreenProps<ProductStackParams, 'ProductDetail'>) {
const { productId, title } = route.params;
return (
<View style={styles.container}>
<Text style={styles.heading}>{title}</Text>
<Text>Product ID: {productId}</Text>
<TouchableOpacity
style={styles.button}
onPress={() => navigation.goBack()}
>
<Text style={styles.buttonText}>Go Back</Text>
</TouchableOpacity>
</View>
);
}
// The stack navigator itself
export function ProductStack() {
return (
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: '#6366f1' },
headerTintColor: '#fff',
headerTitleStyle: { fontWeight: 'bold' },
}}
>
<Stack.Screen
name="ProductList"
component={ProductListScreen}
options={{ title: 'Our Products' }}
/>
<Stack.Screen
name="ProductDetail"
component={ProductDetailScreen}
options={({ route }) => ({
title: route.params.title,
})}
/>
</Stack.Navigator>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
card: {
padding: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: '#e5e7eb',
},
title: { fontSize: 16, fontWeight: '600' },
price: { marginTop: 4, color: '#6b7280' },
heading: { fontSize: 22, fontWeight: 'bold', marginBottom: 12 },
button: {
marginTop: 20,
padding: 12,
borderRadius: 8,
alignItems: 'center',
backgroundColor: '#6366f1',
},
buttonText: { color: '#fff', fontWeight: '600' },
});ProductStackParams defines what each screen expects. TypeScript gives you autocompletion on navigation.navigate() and catches missing params before runtime. Worth the setup cost.
navigate() pushes. route.params receives. goBack() pops. Android hardware back button and iOS swipe-back work automatically.
The function form on the detail screen's options prop gives access to route params for dynamic header titles. screenOptions on the navigator sets defaults; per-screen options overrides.
Tab Navigator: Bottom and Top Tabs
Every app on your phone has a bottom tab bar. Users understand it without explanation. This is where React Native navigation gets complex, because tabs usually contain stack navigators, and nesting navigators is where the bugs live.
Before the code: the screenOptions function receives the current route, which lets you swap icons based on which tab is active. The tabBarBadge prop on any screen shows a notification count. And tab screens are lazy by default (lazy: true): each one mounts the first time it is focused. Set lazy: false on a screen, or in screenOptions, to render it up front so the first visit is instant, at the cost of more work at startup.
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import { HomeScreen, SearchScreen, NotificationsScreen, ProfileScreen } from '../screens';
type TabParams = {
Home: undefined;
Search: undefined;
Notifications: undefined;
Profile: undefined;
};
type IconName = React.ComponentProps<typeof Ionicons>['name'];
const Tab = createBottomTabNavigator<TabParams>();
export function MainTabs() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName: IconName;
switch (route.name) {
case 'Home':
iconName = focused ? 'home' : 'home-outline';
break;
case 'Search':
iconName = focused ? 'search' : 'search-outline';
break;
case 'Notifications':
iconName = focused ? 'notifications' : 'notifications-outline';
break;
case 'Profile':
iconName = focused ? 'person' : 'person-outline';
break;
default:
iconName = 'ellipse';
}
return <Ionicons name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: '#6366f1',
tabBarInactiveTintColor: '#9ca3af',
tabBarStyle: {
borderTopWidth: 0,
elevation: 10,
shadowOpacity: 0.1,
shadowRadius: 10,
},
headerShown: false,
})}
>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen
name="Notifications"
component={NotificationsScreen}
options={{ tabBarBadge: 3 }}
/>
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}When users switch tabs, state survives — scroll position, form data, all preserved. Big UX win.
Update badge counts dynamically from anywhere using navigation.setOptions(). Top tabs? @react-navigation/material-top-tabs. Same API, renders at top with swipe-to-switch. Swap createBottomTabNavigator for createMaterialTopTabNavigator.
Drawer Navigator
Slides in from the screen edge. More top-level sections than a tab bar can hold? Drawer. Settings and secondary features tucked out of the way? Drawer. Gmail, Slack, both use it.
Requires react-native-gesture-handler and react-native-reanimated:
import React from 'react';
import { View, Text, Image, StyleSheet } from 'react-native';
import {
createDrawerNavigator,
DrawerContentScrollView,
DrawerItemList,
DrawerItem,
type DrawerContentComponentProps,
} from '@react-navigation/drawer';
import { MainTabs } from './MainTabs';
import { SettingsScreen, HelpScreen } from '../screens';
const Drawer = createDrawerNavigator();
// Custom drawer content with a user profile header
function CustomDrawerContent(props: DrawerContentComponentProps) {
return (
<DrawerContentScrollView {...props}>
<View style={styles.profileHeader}>
<Image
source={{ uri: 'https://i.pravatar.cc/100' }}
style={styles.avatar}
/>
<Text style={styles.username}>Alex Johnson</Text>
<Text style={styles.email}>alex@example.com</Text>
</View>
{/* Renders the auto-generated drawer items */}
<DrawerItemList {...props} />
{/* Custom drawer item for logout */}
<DrawerItem
label="Log Out"
onPress={() => console.log('Logging out...')}
labelStyle={{ color: '#ef4444' }}
/>
</DrawerContentScrollView>
);
}
export function AppDrawer() {
return (
<Drawer.Navigator
drawerContent={(props) => <CustomDrawerContent {...props} />}
screenOptions={{
drawerActiveBackgroundColor: '#eef2ff',
drawerActiveTintColor: '#6366f1',
headerShown: false,
}}
>
<Drawer.Screen name="MainTabs" component={MainTabs}
options={{ title: 'Home' }} />
<Drawer.Screen name="Settings" component={SettingsScreen}
options={{ title: 'Settings' }} />
<Drawer.Screen name="Help" component={HelpScreen}
options={{ title: 'Help & Support' }} />
</Drawer.Navigator>
);
}
const styles = StyleSheet.create({
profileHeader: {
padding: 20,
marginBottom: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: '#e5e7eb',
},
avatar: { width: 64, height: 64, borderRadius: 32, marginBottom: 12 },
username: { fontSize: 18, fontWeight: 'bold' },
email: { fontSize: 14, color: '#6b7280' },
});drawerContent fully customizes the panel — profile header above, custom "Log Out" below. DrawerItemList renders all Drawer.Screen entries automatically between them.
Swipe from the left edge or tap a hamburger. Programmatic: navigation.openDrawer(), navigation.closeDrawer(). Configurable via drawerPosition for right-side drawers.
Nesting Navigators
A common production shape: Root Stack (auth vs. app) > Tab Navigator (main sections) > Stack Navigator per tab (detail screens). Root stack decides logged-in vs. logged-out. Tabs give instant section switching. Each tab gets its own stack so drilling into a detail view doesn't blow up the other tabs. Here that tab layer is AppTabs; the root stack that decides between login and AppTabs is the RootNavigator in the next section.
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import {
HomeScreen,
DetailScreen,
SearchScreen,
ProfileScreen,
SettingsScreen,
} from '../screens';
// Home tab has its own stack for detail navigation
const HomeStack = createNativeStackNavigator();
function HomeStackNavigator() {
return (
<HomeStack.Navigator>
<HomeStack.Screen
name="HomeFeed"
component={HomeScreen}
options={{ title: 'Home' }}
/>
<HomeStack.Screen
name="Detail"
component={DetailScreen}
options={{ title: 'Details' }}
/>
</HomeStack.Navigator>
);
}
// Profile tab has its own stack for settings
const ProfileStack = createNativeStackNavigator();
function ProfileStackNavigator() {
return (
<ProfileStack.Navigator>
<ProfileStack.Screen
name="ProfileMain"
component={ProfileScreen}
options={{ title: 'My Profile' }}
/>
<ProfileStack.Screen
name="Settings"
component={SettingsScreen}
options={{ title: 'Settings' }}
/>
</ProfileStack.Navigator>
);
}
// The app's tab navigator, nesting one stack per tab
const Tab = createBottomTabNavigator();
export function AppTabs() {
return (
<Tab.Navigator screenOptions={{ headerShown: false }}>
<Tab.Screen name="HomeTab" component={HomeStackNavigator} />
<Tab.Screen name="SearchTab" component={SearchScreen} />
<Tab.Screen name="ProfileTab" component={ProfileStackNavigator} />
</Tab.Navigator>
);
}headerShown: false on the tab navigator. Without it, both the tab and the nested stack render separate headers. Double headers. Looks awful. Rule: only the innermost navigator shows a header.
Navigating across nesting boundaries: navigation.navigate('ProfileTab', { screen: 'Settings' }). Switches to the Profile tab, then navigates to Settings within that tab's stack. Pass params along: navigation.navigate('ProfileTab', { screen: 'Settings', params: { section: 'notifications' } }).
And a common surprise: users navigate deep into a tab, switch away, come back — they see the detail screen, not the root. By design. Pressing the tab icon again while that tab is focused already pops its nested stack back to the root (and scrolls to the top if the screen uses useScrollToTop); no listener needed. If you want a tab to reset whenever the user leaves it, set popToTopOnBlur: true in that tab's options.
Authentication Flow Pattern
Conditionally render different navigation trees based on auth state. Logged out? Login screens. Logged in? Main app. No navigation.navigate('Home') after login. The entire tree swaps.
Why this matters: unauthenticated users can't reach protected screens through deep links or back-button tricks. Those screens don't exist in the tree. Gone. The transition is atomic.
import React, { createContext, useContext, useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppTabs } from './AppTabs';
// Auth context to share auth state across the app
type AuthContextType = {
isLoggedIn: boolean;
isLoading: boolean;
signIn: (token: string) => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Check for existing token on app launch
useEffect(() => {
async function checkAuth() {
try {
const token = await AsyncStorage.getItem('userToken');
setIsLoggedIn(token !== null);
} finally {
setIsLoading(false);
}
}
checkAuth();
}, []);
const authContext: AuthContextType = {
isLoggedIn,
isLoading,
signIn: async (token) => {
await AsyncStorage.setItem('userToken', token);
setIsLoggedIn(true);
},
signOut: async () => {
await AsyncStorage.removeItem('userToken');
setIsLoggedIn(false);
},
};
return (
<AuthContext.Provider value={authContext}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}
// Login screen component
function LoginScreen() {
const { signIn } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async () => {
// Call your API, then store the token
const token = 'fake-jwt-token'; // Replace with real API call
await signIn(token);
// No navigation.navigate() needed!
// The navigation tree swaps automatically
};
return (
<View style={{ flex: 1, padding: 20, justifyContent: 'center' }}>
<Text style={{ fontSize: 28, fontWeight: 'bold', marginBottom: 30 }}>
Welcome Back
</Text>
<TextInput placeholder="Email" value={email}
onChangeText={setEmail} autoCapitalize="none" />
<TextInput placeholder="Password" value={password}
onChangeText={setPassword} secureTextEntry />
<TouchableOpacity onPress={handleLogin}>
<Text>Sign In</Text>
</TouchableOpacity>
</View>
);
}
// The one root navigator: renders the login flow or the app
const Stack = createNativeStackNavigator();
export function RootNavigator() {
const { isLoggedIn, isLoading } = useAuth();
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#6366f1" />
</View>
);
}
return (
<Stack.Navigator
screenOptions={{ headerShown: false }}
// Experimental (React Navigation 7): replay a deep link that arrived
// before login once its screen exists. See the deep-linking section.
UNSTABLE_routeNamesChangeBehavior="lastUnhandled"
>
{isLoggedIn ? (
<Stack.Screen name="App" component={AppTabs} />
) : (
<Stack.Screen name="Login" component={LoginScreen} />
)}
</Stack.Navigator>
);
}Conditional rendering inside Stack.Navigator is the key. When isLoggedIn flips to true, React Navigation automatically transitions from Login to App with a smooth animation. No imperative navigate call. Navigation state driven entirely by auth state.
The isLoading check: without it, users see a flash of login before the main app. AsyncStorage takes a moment to check for an existing token. Show a spinner during that moment.
Security benefit people miss: screens that aren't rendered don't exist. Nothing to navigate to through back-button manipulation.
Deep Linking and Universal Links
Nobody sets this up until production. Then a push notification needs to open a specific screen and suddenly it's urgent.
React Navigation has built-in deep linking. The config object maps URLs to screens. Study it:
import React from 'react';
import { ActivityIndicator } from 'react-native';
import { NavigationContainer, type LinkingOptions } from '@react-navigation/native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider, RootNavigator } from './navigation/RootNavigator';
// URL-to-screen mapping. Names mirror the navigators:
// RootNavigator (App | Login) > AppTabs (HomeTab | SearchTab | ProfileTab) > stacks
const linking: LinkingOptions<ReactNavigation.RootParamList> = {
prefixes: [
'myapp://', // Custom URL scheme
'https://myapp.com', // Universal link (iOS) and App Link (Android)
],
config: {
screens: {
App: {
screens: {
HomeTab: {
screens: {
HomeFeed: 'home',
Detail: 'detail/:id',
},
},
SearchTab: 'search',
ProfileTab: {
screens: {
ProfileMain: 'profile',
Settings: 'settings',
},
},
},
},
Login: 'login',
},
},
};
export default function App() {
return (
<SafeAreaProvider>
<AuthProvider>
<NavigationContainer linking={linking}
fallback={<ActivityIndicator size="large" />}>
<RootNavigator />
</NavigationContainer>
</AuthProvider>
</SafeAreaProvider>
);
}Config structure mirrors navigator nesting. prefixes lists URL schemes. config.screens maps names to URL patterns. Opening myapp://detail/42 navigates to Detail inside HomeTab inside App, passing { id: '42' }.
:id in 'detail/:id' is a path parameter. Same syntax as Express. Query parameters work too: myapp://search?q=headphones passes { q: 'headphones' }.
Universal links (iOS) and app links (Android) need platform-specific setup. iOS: Associated Domains entitlement plus an apple-app-site-association file on your domain. Android: intent filters in AndroidManifest.xml plus a Digital Asset Links file. Both are tedious. Both are mandatory for production.
Deep linking interacts with auth. Someone opens myapp://detail/42 while logged out? Only Login exists, so they see the login screen and, by default, the link is not replayed after sign-in. React Navigation 7 has an experimental opt-in for this: UNSTABLE_routeNamesChangeBehavior="lastUnhandled" on the navigator whose screens change (the root stack in RootNavigator.tsx). It remembers the last screen it couldn't handle and navigates there once that screen becomes available. The UNSTABLE_ prefix means the API may change in a minor release, so check the authentication-flow docs for your exact version.
Testing: xcrun simctl openurl on iOS simulator, adb shell am start on Android.
Practical Advice and What I Would Skip
Is React Native navigation as good as native? No. The gesture handling is slightly off. Complex shared element transitions are painful. Performance degrades with deeply nested navigators in ways that native navigation doesn't. But it's good enough for most apps, and the alternative is maintaining two native codebases. That's the actual tradeoff.