Skip to content

File Based Routing with Expo Router

File Based Routing with Expo Router

Introduction

If there is one thing most Javascript developers love, it is file-based routing, and with the advent of frameworks like Next.js, this form of routing has become more popular and most preferred when building applications. The React Native community will not be left out, as earlier this year, Expo released the Expo router, which implements the file-based routing convention. Currently, the Expo router is in V2, which is supported by version 49 of the Expo SDK.

In this article, we will take a look at the features that the Expo router offers and explore its file-based routing system.

What is Expo Router?

Expo Router is a file-based router for React Native and web applications. It allows you to manage navigation between screens in your app, allowing users to move seamlessly between different parts of your app's UI, using the same components on multiple platforms (Android, iOS, and web). It brings the best file-system routing concepts from the web to a universal application, allowing your routing to work across every platform. When a file is added to the app directory, the file automatically becomes a route in your navigation.

Features of Expo Router

  • Native: It is built on the React Navigation suite, Expo Router navigation is truly native and platform-optimized by default.
  • Shareable: Every screen in your app is automatically deep linkable, making any route in your app shareable with links.
  • Offline-first: Handles all incoming native URLs without a network connection or server. Apps are cached and run offline first, with automatic updates when you publish a new version.
  • Optimized: Routes are automatically optimized with lazy evaluation in production and deferred bundling in development.
  • Iteration: Universal Fast Refresh across Android, iOS, and the web, along with artifact memoization in the bundler to keep you moving fast at scale.
  • Universal: Android, iOS, and web share a unified navigation structure, with the ability to drop down to platform-specific APIs at the route level.
  • Discoverable: Expo Router enables build-time static rendering on the web and universal linking to native. Meaning your app content can be indexed by search engines.

Expo routing conventions

Let’s build a simple application to discuss the various routing conventions and explore some features of the Expo router.

To get started, all you need to do is run the following command

npx create-expo-app@latest -e with-router

This will create a bare-bone expo project with an Expo router. By default, it is not configured to use typescript. To add a typescript to the project, go to the root of the project and run:

Touch tsconfig.json
npx expo

Expo also provides a way to create a minimal project with the Expo Router library already installed. To create a project, run the command:

npx create-expo-app@latest --template tabs@49

For this article, we will stick with the first approach.

Route navigation

Now let’s create our first page. Create a file called app/index.tsx and add the following code:

import { Link, useRouter } from 'expo-router';
import { View, Pressable, Text, Button } from 'react-native';
const Login = () => {
  const router = useRouter();
  const handleLogin = () => {
    router.replace('/home');
  };
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Button onPress={handleLogin} title='Login' />
      <Link href="/register" asChild>
        <Pressable>
          <Text>Create account</Text>
        </Pressable>
      </Link>
    </View>
  );
};
export default Login;

From the above code, we see that the expo router provides a Link component for navigation. This is very familiar to how the web works with anchor tag <a> and the href attribute. The Link component wraps the children in a <Text> component by default; this is useful for accessibility but not always desired. You can customize the component by passing the asChild prop, which will forward all props to the first child of the Link component. The child component must support the onPress and onClick props, href and accessibilityRole will also be passed down.

Additionally, we can also use the useRouter hook to navigate to a route. This is very useful if navigating from a global store or custom hook.

Layout Routes

At the moment, any page we create covers the whole screen, and this is not perfect in most native apps. In that case, we can define a layout that will wrap our pages and add a header and footer, or in our case, a native stack component. This follows the same approach as the usual React Native stack, and we can define the different screens based on their file name. Let’s create a root layout app/_layout.tsx file:

import { Stack, useRouter } from 'expo-router';
import { Button } from 'react-native';
const StackLayout = () => {
  const router = useRouter();
  return (
    <Stack
      screenOptions={{
        headerStyle: {
          backgroundColor: '#f4511e',
        },
        headerTintColor: '#fff',
        headerTitleStyle: {
          fontWeight: 'bold',
        },
      }}
    >
      <Stack.Screen name="index" options={{ headerTitle: 'Login' }} />
      <Stack.Screen
        name="register"
        options={{
          headerTitle: 'Create account',
        }}
      />
    </Stack>
  );
};
export default StackLayout;

From the above code, we added the native stack component to define the different screens on the app. We also added screenOptions to add some basic header styles This is useful if you want to define the layout style for all routes. You can also change the layout for each screen by adding various style options to the options property in the <Stack.screen> component.

Furthermore, if you create a directory that groups different routes, you can create a layout for those routes.

Unmatched Routes

From our example above, we do not have a register page, so if we try to click on the link and visit that page, we will get an unmatched error page. Expo router provides a default unmatched route. This is the fallback page when you try to reach a route that does not exist. You can customise this route by creating a file called app/[...unmatched].tsx and exporting any component you want to render instead. Be sure to have a link to ‘/’ so users can navigate back to the home screen.

Groups

Groups are used to organize sections of the app. When we wrap a directory name in parenthesis (e.g., (profile)), it does so without adding segment to the URL. This is mostly useful when you are developing for the web, and you don’t want the group name to exist as a segment on the URL. Let’s create a group named (tabs). This will contain two tab files: home and account.

Create a file name apps/(tabs)/home.tsx and add the following code:

import { View, Text } from 'react-native';
const HomePage = () => {
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text>I am the home screen</Text>
    </View>
  );
};
export default HomePage;

Create another file called apps/(tabs)/account.ts and add the following code:

import { View, Text } from 'react-native';
const AccountPage = () => {
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text>This is the profile page</Text>
    </View>
  );
};
export default AccountPage;

We can now add a layout to the (tabs) group. The layout won’t use a <Stack> component but will use a <Tabs> component. Create a _layout.tsx file inside the (tabs) directory and add the following code:

import { Link, Tabs } from 'expo-router';
import { FontAwesome5 } from '@expo/vector-icons';
import { Pressable, Text } from 'react-native';
const TabsLayout = () => {
  return (
    <Tabs>
      <Tabs.Screen
        name="home"
        options={{
          tabBarLabel: 'Home',
          headerTitle: 'Home',
          tabBarIcon: ({ color, size }) => <FontAwesome5 name="home" size={size} color={color} />,
          headerRight: () => (
            <Link href="/" asChild replace>
              <Pressable>
                <Text>Logout</Text>
              </Pressable>
            </Link>
          ),
        }}
      />
      <Tabs.Screen
        name="account"
        options={{
          tabBarLabel: 'Account',
          headerTitle: 'My Account',
          tabBarIcon: ({ color, size }) => <FontAwesome5 name="user" size={size} color={color} />,
        }}
      />
    </Tabs>
  );
};
export default TabsLayout;

From the above code, we created two tabs for the Home page and the Account page. We added the appropriate icons for those tabs. On the homepage, we added a Logout link. In the Link component, we added a replace attribute because using the Link component is almost the same as calling router.push(). It adds the route on top of the stack. But since this is a Logout link, we need to replace all existing navigation stacks with the route.

By default, you should be able to navigate to the tabs screen, but you will notice that it has multiple header sections as seen below: multiple header sections - react expo router

You will notice that there are two header sections. The first header with the title (tabs) is inferred by the Expo router, and it uses the styling from the root layout. The second header was created when we added some options in the <Tab.screen> component. We can remove any of the headers depending on our app design. To remove the first header, we must add the (tabs) route as a screen to the root layout and set the headerShown to false. So go to the _layout.tsx file on the root of the app directory and add the following code:

<Stack.Screen
  name="(tabs)"
  options={{
    headerShown: false,
  }}
/>

We are setting the headerShown to false because the (tabs) group also has a layout, and each tab on the layout has an option that sets the headerTitle and other header properties. That is the header we would show, as they will change for the different tabs. If we want a static header for both tabs, we can add headerShown: false to all tabs options on the tab layout.

Shared routes

To match the same URL with different layouts, use groups with overlapping child routes. This pattern is very common in native apps. For example, in the Twitter app, a profile can be viewed in every tab (such as home, search, and profile). However, there is only one URL that is required to access this route.

Here is an example of how a shared route is structured: shared routes in expo router

Shared routes can be navigated directly by including the group name in the route. For example, /(search)/victor navigates to /victor in the "search" layout.

Kindly note that for web apps, when you directly type/victor on your browser, the Expo router cannot tell which [user].tsx you are trying to reach, so it gets the first route that matches the path from your file tree (In the case above, it is the (home)/[user].tsx). Shared routes are most useful in tab navigations because it is easier to know the group from which the route was visited and keep the tab active.

Arrays

Instead of defining the same route multiple times with different layouts, we can use the array syntax (,) to duplicate the children of a group. For example, app/(home,profile,search)/[user].tsx creates app/(home)/[user].tsx, app/(profile)/[user].tsx and app/(search)/[user].tsx in memory.

To distinguish between the two routes, use a layout's segment prop:

const DynamicLayout = ({ segment }) => {
  if (segment === '(search)') {
    return <SearchStack />;
  }
    if (segment === (profile)) {
    return <ProfileStack />;
  }
  return <Stack />;
}
export default DynamicLayout;

Dynamic Route

Dynamic routes match any unmatched path at a given segment level. For example, if I have a directory named contacts, I can create a dynamic route for each contact app/contacts/[slug].tsx. This is very useful if you have a list of contacts and you want to show the details of one contact.

Routes with higher specificity will be matched before a dynamic route. For example, contacts/victor will match app/contacts/victor.tsx before app/contacts/[user].tsx.

Multiple slugs can be matched in a single route using the rest of the syntax (...). For example, app/contacts/[…rest].tsx matches /contacts/123/settings. Dynamic segments are accessible as search parameters in the page component.

import { useLocalSearchParams } from 'expo-router';
import { Text } from 'react-native';
Const ContactPage = () => {
  const { slug } = useLocalSearchParams();
  return <Text>Contact {slug}</Text>;
}

export default ContactPage;

Conclusion

In this article, we learned about the Expo Router, how it works, its core features, and its tradeoffs using some example codes. Expo Router offers a minimalistic API with a straightforward approach to navigation using the file-system routing concepts. The Expo router is most useful if you are not dealing with a complex navigation system in your project. File-based routing provides a smooth navigation experience for mobile applications, and Expo Router implements this solution into its library.