Common navigation patterns

Edit this page

Apply Expo Router basics to real-life navigation patterns you could use in your app.


Now that you know the basics of how files and directories are named and arranged in Expo Router, let's apply that knowledge, looking at some real-life navigation patterns you might use in your app.

Stacks inside tabs: nested navigators

If the typical starting point for your app is a set of tabs, but one or more tabs may have more than one screen associated with it, nesting a stack navigator inside of a tab is often the way to go. This pattern often results in intuitive URLs and scales well to desktop web apps, where the primary tabs are often always visible.

Consider the following navigation tree:

app
(tabs)
  _layout.tsx
  index.tsxsingle page tab
  feed
   _layout.tsxtab with a stack inside
   index.tsx
   [postId].tsx
  settings.tsxsingle page tab

In the app/(tabs)_layout.tsx file, return a Tabs component:

app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs screenOptions={{ headerShown: false }}>
      <Tab.Screen name="index" options={{ title: 'Home' }} />
      <Tab.Screen name="feed" options={{ title: 'Feed' }} />
      <Tab.Screen name="settings" options={{ title: 'Settings' }} />
    </Tabs>
  );
}

In the app/(tabs)/feed/_layout.tsx file, return a Stack component:

app/(tabs)/feed/_layout.tsx
import { Stack } from 'expo-router';

export const unstable_settings = {
  initialRouteName: 'index',
};

export default function FeedLayout() {
  return <Stack />;
}

Now, within the app/(tabs)/feed directory, you can have Link components that point to different posts (for example, /feed/123). Those links will push the feed/[postId] route onto the stack, leaving the tab navigator visible.

You can also navigate from any other tab to a post in the feed tab with the same URL. Use withAnchor in conjunction with initialRouteName to ensure that the feed/index route is always the first screen in the stack:

app/(tabs)/feed/index.tsx
<Link href="/feed/123" withAnchor>
  Go to post
</Link>

You can also nest tabs inside of an outer stack navigator. That is often more useful for displaying modals over the tabs.

Nested navigators

Learn more about how to use nested navigators in your Expo Router app.

One screen, two tabs: sharing routes

Route groups can be used to share a single screen between two different tabs. Consider a navigation tree that has a Feed tab and a Search tab, and they both share pages for viewing a user profile:

app
(tabs)
  _layout.tsx
  (feed)
   index.tsxdefault route
  (search)
   search.tsx
  (feed,search)
   _layout.tsxlayout shared between the two tabs
   users
    [username].tsxshared user profile page

Each of the tabs is put in a group so you can define a third directory that shares routes between two groups (app/(tabs)/(feed,search)/). Even with the extra layer, app/(tabs)/(feed)/index.tsx is still the nearest index, so it will be the default route.

app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="(feed)" options={{ title: 'Feed' }} />
      <Tabs.Screen name="search" options={{ title: 'Search' }} />
    </Tabs>
  );
}

Both the (feed) and (search) route groups contain stacks, so they can also share a single layout:

app/(tabs)/(feed,search)/_layout.tsx
import { Stack } from 'expo-router';

export default function SharedLayout() {
  return <Stack />;
}

It's also possible for shared groups to only contain the shared pages, with each distinct group having its own layout file.

Now, both tabs can navigate to /users/evanbacon and see the same user profile page.

When you're already focused on a tab and navigating to a user, you will stay in that current tab's group. But when deep-linking directly to a user profile page from outside the app, Expo Router has to pick one of the two groups, so it will pick the first group alphabetically. Therefore, deep-linking to /users/evanbacon will show the user profile in the Feed tab.

Shared Routes

Learn more about how distinct routes can share the same URL in Expo Router.

Authenticated users only protecting routes

If you have a set of routes that should only be accessible to authenticated users, you can embed those routes in a group whose layout redirects users to a login page if they are not authenticated.

Here's what that looks like:

app
_layout.tsx
login.tsxroutes users back to /(logged-in) after authentication
(logged-in)
  _layout.tsxincludes redirect for unauthenticated users
  (tabs)
   _layout.tsx
   index.tsxdefault route for app
   feed.tsx
  modal.tsx

By default, the app will go to the nearest index, app/(logged-in)/(tabs)/index.tsx. However, a route group's layout file will be rendered before the enclosed route is. So, if the user is not authenticated, at this point, you can redirect the user to the login page:

app/(logged-in)/_layout.tsx
import { Redirect, Stack } from 'expo-router';

export default function AuthLayout() {
  const isAuthenticated = /* check for valid auth token/session */

  if (!isAuthenticated) {
    return <Redirect href="/login" />;
  }

  return <Stack />;
}

Further, if a user tries to navigate to a deep link that's inside the (logged-in) group, they will also be redirected to the login page. Every layout on the way to the route you're navigating to is first rendered before the route itself.

The data source for checking authentication status could be React context, a state library, or a third-party auth framework. In the case of a reactive data source like context, not only will this redirect an unauthenticated user when first entering the app, but it will also redirect them if their session becomes invalid while using the app, as this layout component will re-render at that time.

In the app/login.tsx file, attempt to reroute the user to the /(logged-in) route after successful authentication:

app/login.tsx
import { Button } from 'react-native';
import { useRouter } from 'expo-router';

export default function Login() {
  return (
    <View>
      {/* login form */}
      <Button
        title="Login"
        onPress={() => {
          /* authenticate user */
          router.replace('/(logged-in)');
        }}
      />
    </View>
  );
}

This will cause app/(logged-in)/_layout.tsx to re-render again. This time, the authentication check will pass, and the app will proceed to the default route.

Expo Router Authentication Guide

Follow an in-depth example of implementing authentication using protected routes.

Sometimes the best route isn't a route at all

Separating your navigation states into distinct routes is meant to serve you and your app. Sometimes the best pattern for the job will not involve navigating to another route at all. Since layout files are just React components, you can use them to display all sorts of UI around, besides, or instead of a navigator.

Thinking back to authentication, the protected route setup works great if the user should simply not be able to visit certain pages without logging in. But what about when unauthenticated users can browse an app in read-only mode? In that case, you might want to show a login modal over the app, rather than redirecting the user to a login page:

app/(logged-in)/_layout.tsx
import { SafeAreaView, Modal } from 'react-native';
import { Stack } from 'expo-router';

export default function Layout() {
  const isAuthenticated = /* check for valid auth token / session */

  return (
    <SafeAreaView>
      <Stack />
      <Modal visible={!isAuthenticated}>{/* login UX */}</Modal>
    </SafeAreaView>
  );
}
Modals in Expo Router

Learn multiple patterns for displaying modals in Expo Router, including using a modal inside of a layout file.