If you have ever built a website with Next.js, you already know the joy of creating a file and having a new page show up automatically. No route table, no config, just a file.
Now think about React Native. For a long time, adding a new screen meant creating the component, registering it in a navigator, giving it a name, and then remembering that name every time you wanted to navigate to it.
Expo Router brings the same file-based idea to mobile apps. You create files and folders inside an app directory, and those files become your screens.
In my previous article, we looked at React Navigation Explained. If you haven’t read it yet, I would suggest giving it a quick read first, because it will make a lot of things here feel familiar.
Alright, let’s begin.
What is Expo Router?
Expo Router is a file-based router for React Native and web applications. Every file inside the app directory automatically becomes a route in your app, and the file name decides the URL of that route.
It was originally built on top of React Navigation, but starting with Expo SDK 56 it no longer depends on React Navigation at runtime. The good news is that you still get the same kind of navigators (stack, tabs and so on), just through Expo Router’s own components.
Another cool thing is that every screen in your app automatically gets a URL. That means deep linking works out of the box without you writing extra linking configuration.
Creating a Project
The default Expo template already comes with Expo Router set up, so you don’t need to install anything extra.
bun create expo-app my-app
Once the project is created, you will find an app folder at the root. This is the folder Expo Router looks at to build your routes.
File-Based Routing
The rule is very simple. A file inside the app directory is a screen, and its path is the route.
app
├── _layout.tsx -> wraps all the screens
├── index.tsx -> /
├── about.tsx -> /about
├── settings
│ ├── index.tsx -> /settings
│ └── account.tsx -> /settings/account
└── +not-found.tsx -> shown when no route matches
A few things to notice here:
index.tsxis the default screen of a folder. At the root it is/, insidesettingsit is/settings.- A folder gives you a nested path.
settings/account.tsxbecomes/settings/account. - Files starting with
+are special files.+not-found.tsxis shown when the user opens a route that does not exist. - Files starting with
_such as_layout.tsxare not screens. They define how the screens around them are arranged.
Every route file must export a React component as its default export.
import { Text, View } from "react-native";
export default function About() {
return (
<View>
<Text>About screen</Text>
</View>
);
}
That’s it. Save the file and /about exists.
Layouts
In React Navigation we wrote navigators ourselves and placed screens inside them. In Expo Router, that job is done by the layout file.
A _layout.tsx file defines the shared UI and navigator for every route in its folder. The root app/_layout.tsx is always the first thing rendered in your app, so it is the right place for things like providers, fonts and your top level navigator.
import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
With just this, every file in app becomes a screen in a stack. Pushing a screen adds it on top and going back pops it, exactly like the Stack Navigator we saw earlier.
If you want to customise screens, you can add Stack.Screen inside the layout.
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="about" options={{ title: "About us", headerShown: true }} />
</Stack>
);
}
The name here is simply the file name of the route, without the extension. screenOptions applies to all screens in this layout, while options on a Stack.Screen is only for that one screen.
Navigating Between Screens
Expo Router gives you two ways to navigate. A component and an imperative API.
Link
The Link component is the easiest way to move between screens. It works a lot like the anchor tag on the web.
import { Link } from "expo-router";
export default function Home() {
return <Link href="/about">Go to About</Link>;
}
If you want to use your own button instead of plain text, wrap it using asChild.
import { Link } from "expo-router";
import { Pressable, Text } from "react-native";
<Link href="/about" asChild>
<Pressable>
<Text>Go to About</Text>
</Pressable>
</Link>
The router object
When you need to navigate from code, for example after a form submit or a successful login, use the useRouter hook.
import { useRouter } from "expo-router";
import { Button } from "react-native";
export default function Login() {
const router = useRouter();
return <Button title="Login" onPress={() => router.replace("/")} />;
}
These are the methods you will use most often:
router.push("/about")pushes a new screen on top of the stack.router.navigate("/about")goes to the screen, and if it is already in the stack it goes back to it instead of pushing a duplicate.router.replace("/about")replaces the current screen, so the user can’t go back to it.router.back()goes back to the previous screen.router.dismissAll()pops everything in the stack and takes you back to the first screen.
The replace method is perfect for a login screen. Once the user logs in, you don’t want the back button to take them to the login screen again.
Dynamic Routes
Most apps have screens like a product page or a user profile where the screen is the same but the data is different. For this, Expo Router has dynamic routes.
Wrap the file name in square brackets and that part of the URL becomes a variable.
app
├── index.tsx
└── product
└── [id].tsx -> /product/1, /product/42, /product/anything
Inside the screen, you can read the value using the useLocalSearchParams hook.
import { useLocalSearchParams } from "expo-router";
import { Text, View } from "react-native";
export default function Product() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View>
<Text>Product ID: {id}</Text>
</View>
);
}
And to open it from somewhere else:
<Link href="/product/42">Open product 42</Link>
// or from code
router.push({ pathname: "/product/[id]", params: { id: "42" } });
If you want one file to match any number of segments, you can use a catch-all route like [...rest].tsx. It will match /a, /a/b, /a/b/c and so on. It is often used to build a custom not found screen.
Route Groups
Sometimes you want to organise files into folders without changing the URL. For example, you may want to keep all the tab screens together, and all the auth screens together.
Expo Router solves this with groups. A folder whose name is wrapped in parentheses is a group, and the name is not included in the URL.
app
├── _layout.tsx
├── (tabs)
│ ├── _layout.tsx
│ ├── index.tsx -> /
│ └── search.tsx -> /search
└── (auth)
├── _layout.tsx
└── login.tsx -> /login
Notice that the URL is /search and not /(tabs)/search. The group only exists to organise your files and to attach a layout to them. This is also how the cover image of this article is structured.
Tabs
Now let’s build a bottom tab bar. Since layouts decide the navigator, all we need to do is use Tabs inside the layout of the (tabs) group.
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: "Home" }} />
<Tabs.Screen name="search" options={{ title: "Search" }} />
</Tabs>
);
}
Each file inside (tabs) becomes a tab. If you add a settings.tsx file there, you get a new tab, and there is no registration step. You only add a Tabs.Screen when you want to customise the title, icon or other options.
Expo Router also has a NativeTabs API that uses the platform’s native tab bar, which gives you the real iOS and Android look and feel. It is worth exploring once you are comfortable with the basics.
Nested Layouts
This is where Expo Router really shines. In the React Navigation article we built a tab navigator inside a stack navigator by hand. Here, you get the same result simply by arranging folders.
// app/_layout.tsx
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="settings" />
</Stack>
);
}
app
├── _layout.tsx (Stack)
├── (tabs)
│ ├── _layout.tsx (Tabs)
│ ├── index.tsx
│ └── search.tsx
└── settings.tsx
The root layout is a Stack and the (tabs) group is a child layout using Tabs. When you open settings, it is pushed on the root stack, on top of the tabs. So it covers the tab bar and gives you that full screen experience you see in apps like Instagram.
If you want a screen to slide up like a modal instead, just change the presentation.
<Stack.Screen name="settings" options={{ presentation: "modal" }} />
Protected Routes
Almost every app has some screens that only a logged in user should see. Expo Router handles this with Stack.Protected.
import { Stack } from "expo-router";
import { useAuth } from "../hooks/useAuth";
export default function RootLayout() {
const { isLoggedIn } = useAuth();
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="(tabs)" />
</Stack.Protected>
<Stack.Protected guard={!isLoggedIn}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
</Stack>
);
}
Here useAuth() gives us a boolean isLoggedIn. When it is true, the user can only reach the (tabs) screens. When it is false, they can only see the (auth) screens. If someone tries to open a protected URL directly, they won’t get in.
This is the same idea as the conditional screens we wrote with dynamic configuration in React Navigation, just with a much cleaner syntax.
Not Found Screen
What happens when a user opens a route that does not exist? That is what +not-found.tsx is for.
// app/+not-found.tsx
import { Link } from "expo-router";
import { Text, View } from "react-native";
export default function NotFound() {
return (
<View>
<Text>This screen does not exist.</Text>
<Link href="/">Go to home</Link>
</View>
);
}
This is a small file, but it saves your users from a blank screen when a bad link is opened.
Deep Linking
Since every screen is a URL, deep linking comes almost for free. If your app has a scheme defined in app.json, you can open any screen from outside the app.
{
"expo": {
"scheme": "myapp"
}
}
Now myapp://product/42 opens the product screen with the id 42. The same URL structure works on the web too, so the screen you build for mobile can also be reached from a browser link.
In React Navigation, you have to write a linking config yourself to get this. Here, the file structure already is the config.
Expo Router vs React Navigation
So which one should you pick?
- If you are starting a new Expo project, go with Expo Router. It is the default for a reason, and you get file-based routes, deep linking and typed routes with very little setup.
- If you are working on a bare React Native CLI app, or an existing app that already uses React Navigation, React Navigation is still a solid choice.
Both aim to solve the same problem. The real difference is how you describe your navigation tree. One uses code, and the other uses your folder structure.
Conclusion
You now know how Expo Router works, from files and layouts to dynamic routes, groups, tabs, protected routes and deep linking. Once the folder structure clicks in your head, adding a new screen feels like nothing more than creating a new file.
Until the next article lands, why not router.back() to the previous articles and give them a read?