React Navigation: Charting a Course Through Your React Native App
Open a brand-new React Native project and try to move from one screen to another. You will quickly notice something the web spoiled you on is simply missing: there are no URLs, no history, no browser back button, and no notion of a "screen" at all. A fresh app is a single blank canvas, and the moment your product needs more than one view, you have to invent the entire concept of navigation yourself.
React Navigation is the library the React Native community reaches for to avoid doing exactly that. With roughly five million weekly downloads on its core package, it is the ecosystem standard for in-app routing. It gives you a navigation state tree, a back stack, platform-correct headers and gestures, deep linking, Android hardware back button handling, and typed parameters passed between screens. It is written almost entirely in TypeScript with only thin native modules underneath, so you can read and customize how navigation works without touching Swift or Kotlin. If you have used Expo Router, you have already used React Navigation: Expo Router is built directly on top of it.
What You Get in the Box
React Navigation is not a single package but a monorepo of composable pieces you mix and match:
- A core (
@react-navigation/native) that ties navigation into React Native through theNavigationContainer, handling the back button, deep links, and state subscriptions. - Navigators that define a group of screens and how you move between them: a native stack (
@react-navigation/native-stack), a fully customizable JS stack (@react-navigation/stack), bottom tabs, swipeable material top tabs, and a slide-out drawer. - Deep linking that maps
myapp://andhttps://URLs to screens and back again, with anautomode that can generate paths for you. - First-class TypeScript support, including automatic type inference when you use the static API.
- Nesting, so a stack can contain a tab navigator whose tabs each contain their own stacks, mirroring real app structure.
- React Native Web support, so the same navigation tree can run in a browser.
Navigators are the heart of it. The native stack is the recommended default because push and pop animations and gestures are handled by the operating system, giving you authentic large titles and iOS-style swipe-back for free. When you need bespoke transitions or full control over the animation, you drop down to the JS stack instead.
Getting It Installed
The core package and a navigator are the minimum, but navigators rely on a few native peer dependencies that are easy to forget. The native stack needs react-native-screens and react-native-safe-area-context.
npm install @react-navigation/native @react-navigation/native-stack
npm install react-native-screens react-native-safe-area-context
yarn add @react-navigation/native @react-navigation/native-stack
yarn add react-native-screens react-native-safe-area-context
In an Expo project these peers are wired up for you. In a bare React Native app you need to run a pod install and rebuild after adding them, otherwise you will hit confusing runtime errors like blank screens or gesture crashes. If you later reach for the JS stack, add react-native-gesture-handler and react-native-reanimated as well.
Wiring Up Your First Screens
The classic, long-standing approach is the dynamic API: you declare your screens as JSX children at runtime. You create a navigator, drop a Navigator and some Screen components inside a NavigationContainer, and move around with the navigation object handed to every screen.
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Button, Text } from 'react-native';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
function HomeScreen({ navigation }) {
return (
<Button
title="Go to Profile"
onPress={() => navigation.navigate('Profile', { userId: 123 })}
/>
);
}
function ProfileScreen({ route }) {
const { userId } = route.params;
return <Text>User ID: {userId}</Text>;
}
That second argument to navigate is the parameter object, and the receiving screen reads it from route.params. This pattern of pushing a screen along with some data is the workhorse of almost every mobile app.
Typing the Journey
Untyped navigation is a recipe for typos and missing params. With the dynamic API you describe a param list and hand it to your navigator, which makes both route.params and the navigation methods type-safe.
import { createNativeStackNavigator, NativeStackScreenProps } from '@react-navigation/native-stack';
type RootStackParamList = {
Home: undefined; // no params
Profile: { userId: string }; // required params
Feed: { sort: 'latest' | 'top' } | undefined; // optional params
};
const Stack = createNativeStackNavigator<RootStackParamList>();
type Props = NativeStackScreenProps<RootStackParamList, 'Profile'>;
function ProfileScreen({ route, navigation }: Props) {
const { userId } = route.params; // typed as string
return null;
}
To make useNavigation(), Link, and friends typed everywhere without importing the param list each time, register it globally with module augmentation:
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}
Now an invalid screen name or a missing required param is a compile error rather than a crash on a tester's phone.
The Static API: Describing the Whole Tree at Once
React Navigation v7 introduced a second way to define navigation: the static API. Instead of JSX children, you describe the entire tree as a plain configuration object and convert it into a component with createStaticNavigation. This is the headline feature of v7, and its payoff is that TypeScript types are inferred automatically from the config, so there is no param list to maintain by hand. route.params is typed inside your options and listener callbacks for free.
import { createStaticNavigation } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
const RootStack = createNativeStackNavigator({
initialRouteName: 'Home',
screenOptions: {
headerTintColor: 'white',
headerStyle: { backgroundColor: 'tomato' },
},
screens: {
Home: HomeScreen,
Profile: {
screen: ProfileScreen,
options: ({ route }) => ({ title: `${route.params.userId}'s profile` }),
},
},
});
const Navigation = createStaticNavigation(RootStack);
export default function App() {
return <Navigation />;
}
Deep linking gets dramatically simpler too. Because the static config already knows every screen, you can ask it to generate paths for all of them with a single setting:
const linking = {
enabled: 'auto' as const, // generate paths for all leaf screens
prefixes: ['https://example.com', 'example://'],
};
// <Navigation linking={linking} />
One thing to watch: the linking prop on createStaticNavigation is intentionally slimmer than its dynamic counterpart. It accepts path, initialRouteName, and prefixes, but the per-screen path configuration now lives in the screen definitions themselves rather than in a big nested config object.
The Escape Hatch: Running Hooks with .with()
The static API has one obvious limitation. A configuration object is, by definition, static. You cannot call a hook inside it, read from context, or inject a runtime value the way you naturally can inside JSX. For a long time this meant any genuinely dynamic navigator forced you back to the dynamic API, splitting your app across two styles.
The .with() method closes that gap. Every navigator returned by a createXNavigator call exposes .with(), which wraps the navigator in a component that receives the Navigator itself as a prop. That handed-back Navigator is the static-API equivalent of the dynamic API's navigator component, so inside the wrapper you are free to run hooks, read context, and render providers while keeping your clean static tree everywhere else.
A common use is driving screenOptions from a hook, for example switching a drawer between permanent and overlay modes based on screen size:
import { createDrawerNavigator } from '@react-navigation/drawer';
const MyDrawer = createDrawerNavigator({
screens: { Home: HomeScreen },
}).with(({ Navigator }) => {
const isLargeScreen = useIsLargeScreen();
return (
<Navigator
screenOptions={{ drawerType: isLargeScreen ? 'permanent' : 'front' }}
/>
);
});
It is equally handy for wrapping a navigator in a context provider so every screen below it can read shared data without prop drilling:
const RootStack = createNativeStackNavigator({
screens: { Home: HomeScreen },
}).with(({ Navigator }) => (
<ExtraDataContext.Provider value={someData}>
<Navigator />
</ExtraDataContext.Provider>
));
This is the detail that makes the static API practical for real-world apps rather than just demos. You get the inference and auto-linking wins of static config, and when you genuinely need runtime behavior, .with() lets you reach for it surgically instead of rewriting the whole tree.
Nesting Navigators for Real App Shapes
Real apps rarely have one flat list of screens. They have tabs where each tab is a stack, or a stack that pushes a screen which is itself a set of tabs. React Navigation lets navigators nest arbitrarily, so you compose the shape you actually want.
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const HomeTabs = createBottomTabNavigator({
screens: {
Feed: FeedScreen,
Notifications: NotificationsScreen,
},
});
const RootStack = createNativeStackNavigator({
screens: {
HomeTabs: {
screen: HomeTabs,
options: { headerShown: false },
},
Settings: SettingsScreen,
},
});
const Navigation = createStaticNavigation(RootStack);
Here the root is a native stack whose first "screen" is an entire bottom-tab navigator. Tapping into Settings pushes a normal stack screen on top of the tabbed home, which is exactly how most production apps are structured.
How It Fits Next to Expo Router
It is worth knowing where React Navigation sits in the broader ecosystem. Expo Router is a superset built directly on top of it: it swaps manual screen registration for file-based routing, in the spirit of frameworks like Next.js, and derives deep links automatically from your file paths. Because it sits on the same engine, any React Navigation component or API still works underneath. For brand-new Expo projects, file-based routing is often the recommended default, and you drop down to React Navigation directly when you need finer control or when you are working in a bare React Native app where file-based conventions are not a fit.
The other well-known alternative, Wix's react-native-navigation, renders each screen as a true native screen with no single shared JS root. That can feel slightly more native, but it comes with a more imperative model and is not available in Expo Go. React Navigation's JS-first architecture, enormous community, and best-in-class documentation are why it remains the default answer for most teams.
Where It Is Heading
React Navigation is actively maintained, and v7 is the current stable release. The v8 line is in alpha and has been deliberate about minimizing breaking changes: it brings better TypeScript types and makes native bottom tabs the default, building on the native tab primitives introduced during the v7 cycle. Progress reports point further toward modern React, leaning into features like Suspense, Standard Schema for validating params, and platform-aware colors. The throughline is clear: keep the friendly composable API that made the library a standard, while quietly getting more native and more type-safe underneath.
Wrapping Up
React Native gives you a blank canvas and no map. React Navigation hands you the map, the compass, and the well-worn roads that millions of apps already travel. Start with the native stack for fast, native-feeling transitions, lean on the static API for clean configuration and automatic typing, and keep .with() in your back pocket for the moments when a navigator needs to read a hook or wrap a provider. With those three ideas you can build everything from a two-screen prototype to a deeply nested production app, all without writing a line of native code.