
icture this: A user downloads your mobile app, excited to try it out — but within minutes, they’re frantically tapping the back button, lost in a maze of screens and confused about how to find what they need.
Whether you’re building a social platform, an e-commerce app, or a content-rich service, navigation is the backbone of any mobile application. A well-designed, intuitive flow improves user experience and boosts engagement by helping users find what they need quickly. This is where Expo Router comes in. It is a cross-platform, file-based router built on top of React Navigation, designed to provide a more intuitive and organized navigation setup.
Since it’s still relatively new in the broader ecosystem of routing solutions, the navigation structure can be unclear for new users.
So in this guide, we’ll demystify Expo Router by focusing on three essential aspects of mobile navigation:
- Bottom tab navigation — Ideal for primary application sections
- Top tab navigation — Useful for categorizing content within a section
- Handling full-screen routes — Great for viewing detailed content or other user flows
We’ll break down how to set them up and manage navigation transitions between tabs and full-screen views.
☑️ What We’ll Build:

🏁 Initializing the application:
To make things easy let’s begin by setting up our application using a template Expo already provides:
npx create-expo-app@latest
📝 Note — This template will probably already have the bottom tabs set up, but let’s clean it up so we learn to do everything from scratch .Run the already provided script to do so:
npm run reset -project
This script is used to reset the project to a blank state. It will ask if it can move the starter code to the app-example directory and create a blank appdirectory where you can start developing, we will will deny the creation of the example directory for now.
Tracking the application’s folder structure is crucial while implementing file-based routing. Ours should now look like this:

While we’re at it, let’s also install a future dependency that will be needed in the next section to implement top tabs navigation: @react-navigation/material-top-tabs
npx expo install @react-navigation/material-top-tabs
💡 Tip — Sometimes react-native-pager-view is a package that is not installed by default but is required for this to work, so please check and install if absent. This component allows the user to swipe left and right through screens.npx expo install react- native -pager-view
Now let’s run the app using npm run ios for iOS and npm run android for Android devices. Alternatively, you can use npm start and then press i for iOS or a for Android.
⚠️ Pre-requisite : The above step will require having simulators for iOS or Android already set up and ready to use .Throughout the process, we will focus solely on the app directory, as it will manage all the routes. Anything outside this directory will be treated as standalone pages or reusable components. Since all route names are case-sensitive in Expo Router, all filenames will be in lowercase or camelCase.
📍Bottom Tabs Navigation:
Let’s start by creating a (bottom-tabs) directory and moving the existing index.tsx file inside that directory. The parentheses will exclude this grouped directory from being included in the navigation path.
Now the remaining _layout.tsxfile will be used to specify the root directory’s app layout.
🤔 What is a _layout file? In native apps, users expect shared elements like headers and tab bars to persist between pages. These are created using layout routes. (Expo Router Documentation, Section: Layout Routes )app/_layout.tsx:
import {
Stack }
from "expo-router" ;
export default
function RootLayout () {
return ( <Stack screenOptions={{
headerShown: false }}> <Stack.Screen name="(bottom-tabs)" /> </Stack> ); }
Stack }
from "expo-router" ;
export default
function RootLayout () {
return ( <Stack screenOptions={{
headerShown: false }}> <Stack.Screen name="(bottom-tabs)" /> </Stack> ); }
Within the (bottom-tabs)group, we’ll create files for the individual pages we want to navigate through using the bottom tabs. Additionally, we’ll include a _layout.tsx file to define the layout for this group of routes.
Modifying app/index.tsx:
import {
View , Text }
from "react-native" ;
export default
function Home () {
return ( <View style={{
flex: 1, justifyContent: 'center', alignItems: 'center', }}> <Text>Home</Text> </View> ) }
View , Text }
from "react-native" ;
export default
function Home () {
return ( <View style={{
flex: 1, justifyContent: 'center', alignItems: 'center', }}> <Text>Home</Text> </View> ) }
Adding another page (bottom-tabs)/messages.tsx:
import {
View , Text }
from "react-native" ;
export default
function Messages () {
return ( <View style={{
flex: 1, justifyContent: 'center', alignItems: 'center', }}> <Text>Messages</Text> </View> ) }
View , Text }
from "react-native" ;
export default
function Messages () {
return ( <View style={{
flex: 1, justifyContent: 'center', alignItems: 'center', }}> <Text>Messages</Text> </View> ) }
Adding (bottom-tabs)/_layout.tsx:
import {
Tabs }
from "expo-router" ;
import React
from "react" ;
import {
FontAwesome }
from "@expo/vector-icons" ;
export default
function BottomTabsLayout () {
return ( <Tabs screenOptions={{
tabBarActiveTintColor: 'black', headerTitleAlign: 'center' }}> <Tabs.Screen name="index" options={{
title: 'Home', tabBarIcon: ({
color }) => <FontAwesome size={28}
name="home" color={color} />, }} /> <Tabs.Screen name="messages" options={{
title: 'Messages', tabBarIcon: ({
color }) => <FontAwesome size={25}
name="envelope-open" color={color} />, }} /> </Tabs> ); }
Tabs }
from "expo-router" ;
import React
from "react" ;
import {
FontAwesome }
from "@expo/vector-icons" ;
export default
function BottomTabsLayout () {
return ( <Tabs screenOptions={{
tabBarActiveTintColor: 'black', headerTitleAlign: 'center' }}> <Tabs.Screen name="index" options={{
title: 'Home', tabBarIcon: ({
color }) => <FontAwesome size={28}
name="home" color={color} />, }} /> <Tabs.Screen name="messages" options={{
title: 'Messages', tabBarIcon: ({
color }) => <FontAwesome size={25}
name="envelope-open" color={color} />, }} /> </Tabs> ); }
The file structure should now look like this:

Our scaffold with the bottom tabs should look like this:

🛣️ Routes: Bottom Tabs
---> HOME PAGE ROUTE messages ---> MESSAGES PAGE ROUTE
💡 Tip : As you build your routes, you can use the usePathname() hook from Expo Router to continuously monitor the current routes.📍Top Tabs Navigation:
Let’s now go ahead and set up the top tabs:
Inside the (bottom-tabs)delete the index.tsxand messages.tsx
Create a home directory within the (bottom-tabs) group. Inside it, add pages for the top tabs corresponding to the home bottom tab, along with the necessary _layout.tsx.
home/following.tsx page:
import {
View, Text, StyleSheet }
from 'react-native' ;
export default
function Following () {
return ( <View style={styles.container}> <Text>Posts by people you are following</Text> </View> ); }
const styles = StyleSheet. create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, button : {
paddingVertical : , paddingHorizontal : , borderRadius : , backgroundColor : 'black' , marginTop : , }, text : {
fontSize : , lineHeight : , color : 'white' , }, });
View, Text, StyleSheet }
from 'react-native' ;
export default
function Following () {
return ( <View style={styles.container}> <Text>Posts by people you are following</Text> </View> ); }
const styles = StyleSheet. create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, button : {
paddingVertical : , paddingHorizontal : , borderRadius : , backgroundColor : 'black' , marginTop : , }, text : {
fontSize : , lineHeight : , color : 'white' , }, });
home/forYou.tsx page:
import {
View , Text , StyleSheet }
from 'react-native' ;
export default
function ForYou () {
return ( <View style={styles.container}> <Text>Posts recommended
for you</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
View , Text , StyleSheet }
from 'react-native' ;
export default
function ForYou () {
return ( <View style={styles.container}> <Text>Posts recommended
for you</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
home/_layout.tsx
import {
createMaterialTopTabNavigator, MaterialTopTabNavigationEventMap , MaterialTopTabNavigationOptions , }
from "@react-navigation/material-top-tabs" ;
import {
ParamListBase , TabNavigationState }
from "@react-navigation/native" ;
import {
withLayoutContext }
from "expo-router" ;
const {
Navigator } = createMaterialTopTabNavigator ();
export
const MaterialTopTabs = withLayoutContext< MaterialTopTabNavigationOptions , typeof Navigator , TabNavigationState < ParamListBase >, MaterialTopTabNavigationEventMap >( Navigator );
export default
function HomeLayout () {
return ( <MaterialTopTabs screenOptions={{
tabBarIndicatorStyle: {
backgroundColor: 'black' } }}> <MaterialTopTabs.Screen name="following" options={{
title: "Following" }} /> <MaterialTopTabs.Screen name="forYou" options={{
title: "For you" }} /> </MaterialTopTabs> ); }
createMaterialTopTabNavigator, MaterialTopTabNavigationEventMap , MaterialTopTabNavigationOptions , }
from "@react-navigation/material-top-tabs" ;
import {
ParamListBase , TabNavigationState }
from "@react-navigation/native" ;
import {
withLayoutContext }
from "expo-router" ;
const {
Navigator } = createMaterialTopTabNavigator ();
export
const MaterialTopTabs = withLayoutContext< MaterialTopTabNavigationOptions , typeof Navigator , TabNavigationState < ParamListBase >, MaterialTopTabNavigationEventMap >( Navigator );
export default
function HomeLayout () {
return ( <MaterialTopTabs screenOptions={{
tabBarIndicatorStyle: {
backgroundColor: 'black' } }}> <MaterialTopTabs.Screen name="following" options={{
title: "Following" }} /> <MaterialTopTabs.Screen name="forYou" options={{
title: "For you" }} /> </MaterialTopTabs> ); }
Notice the _layout.tsx file. We’re using Material Top Tabs for the top tabs navigation, which typically expects a component to be passed directly, but we need each tab to function as a separate route. To achieve this, we use the following code to map the routes into individual top tabs:
export
const MaterialTopTabs = withLayoutContext< MaterialTopTabNavigationOptions , typeof Navigator , TabNavigationState < ParamListBase >, MaterialTopTabNavigationEventMap >( Navigator );
const MaterialTopTabs = withLayoutContext< MaterialTopTabNavigationOptions , typeof Navigator , TabNavigationState < ParamListBase >, MaterialTopTabNavigationEventMap >( Navigator );
This code wraps the Navigator from Material Top Tabs with withLayoutContext, making it compatible with the Expo Router. It ensures each tab functions as a route within the navigation structure. The generic types define the navigator’s options (e.g., header, tab labels, styles), state (the state structure of the tab navigator, allowing us to pass any route parameter), and events (e.g., tab presses or tab switches).
Also, update the (bottom-tabs)/_layout.tsx first Tab component’s name from index to home, to accommodate our latest changes.
✅ Ensuring an initial route:
Since we removed the previous index.tsx which was being used as initial entry point, let’s create an app/index.tsx to redirect to the first tab of the home tab which in our case is the following page. This ensures the app always starts with a specific page as its entry point.
import {
Redirect }
from "expo-router" ;
export default
function Index () {
return ( <Redirect href="./home" /> ); }
Redirect }
from "expo-router" ;
export default
function Index () {
return ( <Redirect href="./home" /> ); }
The updated scaffold will look like this:

🛣️ Routes: Home Tab
homefollowing ---> FOLLOWING PAGE ROUTE homeforYou ---> FOR YOU PAGE ROUTE
Adding Pages
Let’s follow the same process for the messages group, by creating pages for Direct, Group, and Starred Messages:
messages/direct.tsx:
import {
View , Text , StyleSheet }
from 'react-native' ;
export default
function Direct () {
return ( <View style={styles.container}> <Text>Direct Messages</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
View , Text , StyleSheet }
from 'react-native' ;
export default
function Direct () {
return ( <View style={styles.container}> <Text>Direct Messages</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
messages/group.tsx:
import {
View , Text , StyleSheet }
from 'react-native' ;
export default
function Group () {
return ( <View style={styles.container}> <Text>Group list</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
View , Text , StyleSheet }
from 'react-native' ;
export default
function Group () {
return ( <View style={styles.container}> <Text>Group list</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
messages/starred.tsx:
import {
View , Text , StyleSheet }
from 'react-native' ;
export default
function Starred () {
return ( <View style={styles.container}> <Text>Starred Messages</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
View , Text , StyleSheet }
from 'react-native' ;
export default
function Starred () {
return ( <View style={styles.container}> <Text>Starred Messages</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
messages/_layout.tsx:
import {
createMaterialTopTabNavigator, MaterialTopTabNavigationEventMap , MaterialTopTabNavigationOptions , }
from "@react-navigation/material-top-tabs" ;
import {
ParamListBase , TabNavigationState }
from "@react-navigation/native" ;
import {
withLayoutContext }
from "expo-router" ;
import FontAwesome
from "@expo/vector-icons/FontAwesome" ;
const {
Navigator } = createMaterialTopTabNavigator ();
export
const MaterialTopTabs = withLayoutContext< MaterialTopTabNavigationOptions , typeof Navigator , TabNavigationState < ParamListBase >, MaterialTopTabNavigationEventMap >( Navigator );
export default
function MessagesLayout () {
return ( <MaterialTopTabs screenOptions={{
tabBarIndicatorStyle: {
backgroundColor: 'black' }, tabBarShowLabel: false }}> <MaterialTopTabs.Screen name="direct" options={{
tabBarIcon: () => <FontAwesome name="user" size={20} />, }} /> <MaterialTopTabs.Screen name="group" options={{
tabBarIcon: () => <FontAwesome name="users" size={20} />, }} /> <MaterialTopTabs.Screen name="starred" options={{
tabBarIcon: () => <FontAwesome name="star" size={20} />, }} /> </MaterialTopTabs> ); }
createMaterialTopTabNavigator, MaterialTopTabNavigationEventMap , MaterialTopTabNavigationOptions , }
from "@react-navigation/material-top-tabs" ;
import {
ParamListBase , TabNavigationState }
from "@react-navigation/native" ;
import {
withLayoutContext }
from "expo-router" ;
import FontAwesome
from "@expo/vector-icons/FontAwesome" ;
const {
Navigator } = createMaterialTopTabNavigator ();
export
const MaterialTopTabs = withLayoutContext< MaterialTopTabNavigationOptions , typeof Navigator , TabNavigationState < ParamListBase >, MaterialTopTabNavigationEventMap >( Navigator );
export default
function MessagesLayout () {
return ( <MaterialTopTabs screenOptions={{
tabBarIndicatorStyle: {
backgroundColor: 'black' }, tabBarShowLabel: false }}> <MaterialTopTabs.Screen name="direct" options={{
tabBarIcon: () => <FontAwesome name="user" size={20} />, }} /> <MaterialTopTabs.Screen name="group" options={{
tabBarIcon: () => <FontAwesome name="users" size={20} />, }} /> <MaterialTopTabs.Screen name="starred" options={{
tabBarIcon: () => <FontAwesome name="star" size={20} />, }} /> </MaterialTopTabs> ); }
🛣️ Routes: Messages Tab
messagesdirect ---> DIRECT MESSAGES PAGE ROUTE messages group ---> GROUP MESSAGES PAGE ROUTE messagesstarred ---> STARRED MESSAGES PAGE ROUTE
Let’s run the app, it should look like this:

Here’s what our file structure will look like with both bottom tabs and top tabs implemented:

📍Full Page Route Navigation:
Many pages will eventually require a full-screen route. But the implementation gets a little unintuitive with Expo Router.
For example, if we want to navigate to a full-screen page from the following tab, it might seem logical to place it under the home directory. However, doing so keeps the top and bottom tabs intact, preventing the page from rendering in full screen. To avoid this, the full-screen page should be created at the app level, ensuring the tabs are not rendered.
So let’s go ahead and add that page app/fullScreenRoute.tsx:
import {
View , Text , StyleSheet }
from 'react-native' ;
export default
function FullScreenRoute () {
return ( <View style={styles.container}> <Text>Full Screen</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
View , Text , StyleSheet }
from 'react-native' ;
export default
function FullScreenRoute () {
return ( <View style={styles.container}> <Text>Full Screen</Text> </View> ); }
const styles = StyleSheet . create ({
container : {
flex : , justifyContent : 'center' , alignItems : 'center' , }, });
Let’s update the following page by adding a button that navigates to the full-screen route when pressed.
export default
function Following () {
const onPressButton = () => {
router. navigate ( '/fullScreenRoute' ); }
return ( <View style={styles.container}> <Text>Posts by people you are following</Text> <Pressable style={styles.button}
onPress={onPressButton}> <Text style={styles.text}>Full screen route</Text> </Pressable> </View> ); }
function Following () {
const onPressButton = () => {
router. navigate ( '/fullScreenRoute' ); }
return ( <View style={styles.container}> <Text>Posts by people you are following</Text> <Pressable style={styles.button}
onPress={onPressButton}> <Text style={styles.text}>Full screen route</Text> </Pressable> </View> ); }
Lastly, let’s update the necessary app/_layout.tsx file to include fullScreenRoute.tsx
import {
Stack }
from "expo-router" ;
export default
function RootLayout () {
return ( <Stack screenOptions={{
headerShown: false }}> <Stack.Screen name="(bottom-tabs)" /> <Stack.Screen name="index" /> <Stack.Screen name="fullScreenRoute" options={{
headerShown: true, title: "Full Screen Route", }}/> </Stack> ); }
Stack }
from "expo-router" ;
export default
function RootLayout () {
return ( <Stack screenOptions={{
headerShown: false }}> <Stack.Screen name="(bottom-tabs)" /> <Stack.Screen name="index" /> <Stack.Screen name="fullScreenRoute" options={{
headerShown: true, title: "Full Screen Route", }}/> </Stack> ); }
Since we’re navigating to a full-screen route, Expo Router automatically provides a back button, as long as there’s a navigation history to trigger the back function.
This is how our final file structure should look:

🛣️ Routes: Full Page Route
fullScreenRoute ---> FULL PAGE ROUTE
And we’re done!
✨ Final Walkthrough:

👋🏻 Wrapping up
Thank you for reading! Whether you’re a seasoned developer or just starting your journey, I hope this was worth your time. If you have any questions, feedback, or want to discuss the topic further, feel free to connect with me on LinkedIn. I’d love to hear your thoughts! Happy coding! 🚀



