Expo Router in 2026: Tabs, Navigation, and Why I Switched from React Navigation
March 2026
Expo Router is file-based navigation for React Native. It replaced React Navigation as the default in Expo SDK 50+, and if you're starting a new Expo app today, you should use Expo Router. It gives you tabs, stacks, deep linking, and TypeScript-safe routes out of the box -- all driven by your file structure instead of manual navigator configuration.
I switched Ship React Native from React Navigation to Expo Router when SDK 50 landed, and I haven't looked back. This guide covers everything I've learned: setting up tabs, nesting stacks inside tabs, handling auth flows, deep linking, and the honest tradeoffs compared to React Navigation.
Table of Contents
How Does Expo Router Work?
Expo Router uses your file system to define routes. Every file inside the app/ directory becomes a route. A file at app/settings.tsx maps to the /settings route. A file at app/profile/edit.tsx maps to /profile/edit. No manual route registration, no NavigationContainer, no createStackNavigator calls.
The magic happens through layout files. A file named _layout.tsx in any directory defines how that directory's routes are rendered -- as a stack, tabs, drawer, or any custom layout. The root app/_layout.tsx is your app's entry point for navigation.
Here's a minimal root layout:
// app/_layout.tsx
import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
That single file gives you stack navigation for every screen in the app/ directory. Expo Router reads your file tree, creates routes for each file, and wraps them in the Stack navigator you defined in the layout.
The _layout.tsx pattern is recursive. You can nest layouts inside subdirectories. A _layout.tsx inside app/(tabs)/ defines the tab navigator. A _layout.tsx inside app/(tabs)/home/ defines a stack navigator nested inside that tab. This composability is what makes Expo Router powerful for real apps.
Route groups use parentheses -- (tabs), (auth), (settings) -- to organize routes without affecting the URL. A file at app/(tabs)/home.tsx has the URL /home, not /(tabs)/home. Groups are purely organizational.
Under the hood, Expo Router still uses React Navigation. It generates React Navigation navigators from your file structure. You get the same native navigation animations and gestures, but without the boilerplate configuration.
Setting Up Tabs with Expo Router
Tabs are the most common navigation pattern in mobile apps, and they're the most searched topic around Expo Router. Here's exactly how to set them up.
First, create the tab layout file:
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: "#007AFF",
tabBarInactiveTintColor: "#8E8E93",
tabBarStyle: {
backgroundColor: "#FFFFFF",
borderTopWidth: 0.5,
borderTopColor: "#E5E5EA",
paddingBottom: 8,
paddingTop: 8,
height: 88,
},
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: "Explore",
tabBarIcon: ({ color, size }) => (
<Ionicons name="compass-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="create"
options={{
title: "Create",
tabBarIcon: ({ color, size }) => (
<Ionicons name="add-circle-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
Then create a screen file for each tab:
// app/(tabs)/index.tsx
import { View, Text } from "react-native";
export default function HomeScreen() {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Home Screen</Text>
</View>
);
}
// app/(tabs)/explore.tsx
export default function ExploreScreen() { / ... / }
// app/(tabs)/create.tsx
export default function CreateScreen() { / ... / }
// app/(tabs)/profile.tsx
export default function ProfileScreen() { / ... / }
Your file structure should look like:
app/
_layout.tsx # Root layout (Stack wrapping tabs)
(tabs)/
_layout.tsx # Tab layout
index.tsx # Home tab
explore.tsx # Explore tab
create.tsx # Create tab
profile.tsx # Profile tab
The root layout needs to render the (tabs) group:
// app/_layout.tsx
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
</Stack>
);
}
For custom tab bar styling, you can pass a completely custom tabBar component:
<Tabs
tabBar={(props) => <CustomTabBar {...props} />}
>
This is useful when you need a floating tab bar, a tab bar with a center action button, or any non-standard design. I use a custom tab bar in Ship React Native to match the app's design system.
Stack Navigation Inside Tabs
Most real apps need stacks inside tabs. You want a Home tab that can push detail screens, a Profile tab that can push an edit screen, and so on. Expo Router handles this with nested directories.
Convert each tab from a single file to a directory with its own layout:
app/
(tabs)/
_layout.tsx
home/
_layout.tsx # Stack for Home tab
index.tsx # Home screen
[id].tsx # Detail screen (dynamic route)
explore/
_layout.tsx # Stack for Explore tab
index.tsx
category/[slug].tsx
profile/
_layout.tsx # Stack for Profile tab
index.tsx
edit.tsx
settings.tsx
Each tab's layout defines a stack:
// app/(tabs)/home/_layout.tsx
import { Stack } from "expo-router";
export default function HomeLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Home" }} />
<Stack.Screen
name="[id]"
options={{ title: "Details" }}
/>
</Stack>
);
}
Update the tab layout to reference the directories instead of files:
// app/(tabs)/_layout.tsx
<Tabs.Screen
name="home"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
Now you can push screens within a tab:
import { Link } from "expo-router";
<Link href="/home/abc123">View Details</Link>
The tab bar stays visible while you navigate within the stack. Press the Home tab icon to pop back to the root. This is the standard iOS/Android pattern.
Expo Router vs React Navigation
I used React Navigation for years before switching. Here's an honest comparison based on shipping production apps with both.
| Feature | Expo Router | React Navigation |
|---|---|---|
| Route definition | File-based, automatic | Manual, imperative |
| Deep linking | Free, based on file paths | Manual configuration required |
| TypeScript routes | Generated from file structure | Manual type definitions |
| Tab navigation | Built-in Tabs component | createBottomTabNavigator |
| Stack navigation | Built-in Stack component | createNativeStackNavigator |
| Custom navigators | Limited to built-in types | Full flexibility to create custom |
| Drawer navigation | Supported via layout | createDrawerNavigator |
| Web support | First-class, URL-based | Possible but not primary focus |
| Learning curve | Lower for new developers | Steeper, more concepts |
| Community examples | Growing, fewer edge cases | Extensive, covers niche patterns |
| Auth patterns | Route groups (auth) | Manual conditional rendering |
| Maturity | Stable since SDK 50 | Battle-tested for 7+ years |
Where Expo Router wins: developer experience, zero-config deep linking, TypeScript route safety, and simpler mental model. If you're building a standard app with tabs, stacks, and auth, Expo Router is faster to set up and easier to maintain.
Where React Navigation wins: flexibility for unusual navigation patterns, custom navigator types, and sheer volume of community examples and Stack Overflow answers. If you need a completely custom navigation experience or you're migrating a large existing app, React Navigation gives you more control.
My take: for any new Expo project, start with Expo Router. You can always drop down to React Navigation APIs when you hit an edge case, since Expo Router is built on top of React Navigation anyway.
Authentication Flow with Expo Router
Handling auth is one of the most asked-about patterns with Expo Router. The solution uses route groups and redirects.
Create two route groups: (auth) for unauthenticated screens and (tabs) for the main app:
app/
_layout.tsx
(auth)/
_layout.tsx
sign-in.tsx
sign-up.tsx
forgot-password.tsx
(tabs)/
_layout.tsx
home/
...
profile/
...
In your root layout, check auth state and redirect accordingly:
// app/_layout.tsx
import { Stack, useRouter, useSegments } from "expo-router";
import { useEffect } from "react";
import { useAuth } from "@/hooks/useAuth";
export default function RootLayout() {
const { user, isLoading } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === "(auth)";
if (!user && !inAuthGroup) {
router.replace("/(auth)/sign-in");
} else if (user && inAuthGroup) {
router.replace("/(tabs)/home");
}
}, [user, isLoading, segments]);
if (isLoading) {
return <LoadingScreen />;
}
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
</Stack>
);
}
The useSegments hook tells you which route group the user is currently in. If they're not authenticated and not in the auth group, redirect them. If they are authenticated and still in the auth group, redirect them to the main app.
This pattern is clean, declarative, and works with any auth provider -- Convex Auth, Clerk, Firebase Auth, or your own JWT-based system. In Ship React Native, I use this exact pattern with Convex Auth, and it handles all the edge cases: token refresh, session expiry, and deep link redirects after login.
How to Navigate Using Expo Router
Expo Router gives you multiple ways to navigate between screens.
The Link component is the simplest approach, similar to web's tag:
import { Link } from "expo-router";
<Link href="/profile/edit">Edit Profile</Link>
// With parameters
<Link href={/home/${item.id}}>View Item</Link>
// As a button
<Link href="/create" asChild>
<Pressable>
<Text>Create New</Text>
</Pressable>
</Link>
The useRouter hook gives you imperative navigation for programmatic use:
import { useRouter } from "expo-router";
export default function SomeScreen() {
const router = useRouter();
const handleSubmit = async () => {
await saveData();
router.push("/home"); // Push onto stack
router.replace("/home"); // Replace current screen
router.back(); // Go back
router.dismiss(); // Dismiss modal
router.dismissAll(); // Dismiss all modals
};
}
The Redirect component handles declarative redirects:
import { Redirect } from "expo-router";
export default function IndexScreen() {
const { user } = useAuth();
if (!user) {
return <Redirect href="/(auth)/sign-in" />;
}
return <Redirect href="/(tabs)/home" />;
}
Typed routes are one of the best features. Expo Router generates types from your file structure, so invalid routes cause TypeScript errors at build time:
// This works if app/(tabs)/home/[id].tsx exists
router.push("/home/abc123");
// This causes a TypeScript error if the route doesn't exist
router.push("/nonexistent-route");
Enable typed routes in your app.json:
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}
Deep Linking
One of the strongest reasons to use Expo Router over React Navigation is deep linking. With React Navigation, setting up deep links requires manual configuration: defining a linking config, mapping URL patterns to screen names, handling nested navigators. It's tedious and error-prone.
With Expo Router, deep linking works automatically. Your file structure defines your URL paths:
app/(tabs)/home/index.tsx -> myapp://home
app/(tabs)/home/[id].tsx -> myapp://home/abc123
app/(tabs)/profile/edit.tsx -> myapp://profile/edit
Configure your URL scheme in app.json:
{
"expo": {
"scheme": "myapp",
"web": {
"bundler": "metro"
}
}
}
That's it. Every route in your app is now a deep link. No additional configuration needed.
For universal links (HTTPS URLs that open your app), you add an associatedDomains configuration:
{
"expo": {
"ios": {
"associatedDomains": ["applinks:yourdomain.com"]
},
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "yourdomain.com" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
Now https://yourdomain.com/home/abc123 opens directly to that screen in your app. The route parameters are automatically parsed and available via useLocalSearchParams:
// app/(tabs)/home/[id].tsx
import { useLocalSearchParams } from "expo-router";
export default function DetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
// id is typed as string, populated from the URL
}
I use deep linking in Ship React Native for share links, push notification routing, and email magic link authentication. With Expo Router, all of these just work without any navigation-level configuration.
How to Structure an Expo Router Project
A well-structured Expo Router project separates navigation concerns from business logic. Here's the structure I use in Ship React Native:
app/
_layout.tsx # Root layout: providers, auth check
index.tsx # Entry redirect
(auth)/
_layout.tsx # Stack layout for auth screens
sign-in.tsx
sign-up.tsx
forgot-password.tsx
(tabs)/
_layout.tsx # Tab navigator
home/
_layout.tsx # Stack for home tab
index.tsx
[id].tsx
explore/
_layout.tsx
index.tsx
create/
_layout.tsx
index.tsx
profile/
_layout.tsx
index.tsx
edit.tsx
settings.tsx
modal.tsx # Global modal screen
+not-found.tsx # 404 screen
components/ # Shared UI components
hooks/ # Custom hooks
lib/ # Utilities, API clients
convex/ # Backend functions (if using Convex)
constants/ # Colors, sizes, config
Key principles I follow:
Keep screen files thin. The files inside app/ should be thin wrappers that import and render screen components from components/ or features/. This keeps your routing layer clean and your business logic testable.
Use route groups liberally. Groups like (auth), (tabs), and (onboarding) organize your navigation without polluting your URLs.
Put the _layout.tsx files next to the screens they manage. Don't try to define all navigation in a single file. Each directory owns its layout.
Use +not-found.tsx for handling invalid routes. Expo Router renders this for any unmatched route.
Common Patterns
Modal Screens
Present a screen as a modal by configuring it in the layout:
// app/_layout.tsx
<Stack>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="modal"
options={{
presentation: "modal",
headerTitle: "Modal Screen",
}}
/>
</Stack>
Navigate to it from anywhere:
router.push("/modal");
The modal slides up over the current screen and can be dismissed with a swipe gesture.
Drawer Navigation
Install the drawer package and use it as a layout:
// app/(drawer)/_layout.tsx
import { Drawer } from "expo-router/drawer";
export default function DrawerLayout() {
return (
<Drawer>
<Drawer.Screen name="index" options={{ title: "Home" }} />
<Drawer.Screen name="settings" options={{ title: "Settings" }} />
</Drawer>
);
}
Nested Navigators
You can combine patterns -- tabs at the root, stacks inside tabs, and modals at the root level. Expo Router handles the nesting automatically based on your file structure:
app/
_layout.tsx # Stack (handles modals)
modal.tsx # Modal screen
(tabs)/
_layout.tsx # Tabs
home/
_layout.tsx # Stack inside Home tab
index.tsx
details/[id].tsx
The key insight: each _layout.tsx defines one level of navigation nesting. The file tree IS the navigation tree.
Shared Routes
If multiple tabs need access to the same screen (like a settings page), you can define it at a higher level:
// app/(tabs)/_layout.tsx
<Tabs>
<Tabs.Screen name="home" />
<Tabs.Screen name="profile" />
<Tabs.Screen
name="settings"
options={{ href: null }} // Hide from tab bar
/>
</Tabs>
Use href: null to keep a screen accessible via navigation without showing it in the tab bar.
When NOT to Use Expo Router
Expo Router is not the right choice for every project. Be honest with yourself about whether it fits.
Bare React Native without Expo. Expo Router requires the Expo ecosystem. If you're using bare React Native with no Expo modules, stick with React Navigation.
Legacy apps deeply invested in React Navigation. If you have a large app with dozens of screens, custom navigators, and complex navigation logic built on React Navigation, migrating to Expo Router is a significant effort. The benefits are real, but the migration cost might not be worth it for an established codebase.
Highly custom navigation experiences. If your app needs a custom drawer that transforms into tabs, custom transition animations between specific screen pairs, or navigation patterns that don't map to standard stack/tab/drawer models, React Navigation's lower-level APIs give you more control.
Offline-first apps with complex state. Expo Router handles navigation state well, but if your app needs to persist and restore deep navigation stacks across app restarts with complex offline state, you might find React Navigation's explicit state management easier to control.
For everything else -- and that covers the vast majority of apps -- Expo Router is the better choice in 2026.
How I Use Expo Router in Ship React Native
Ship React Native uses Expo Router as its entire navigation system. The template ships with tabs, stacks inside each tab, an auth flow with the (auth) group pattern, deep linking, and modal screens -- all pre-configured and working.
The tab layout includes four main tabs: Home, Explore, Create, and Profile. Each tab has its own stack navigator for pushing detail screens. The auth flow uses the redirect pattern I described above, integrated with Convex Auth for Google Sign-In, Apple Sign-In, Magic Link, and Anonymous authentication.
Deep linking is configured for both custom URL schemes and universal links. Push notifications route to the correct screen using Expo Router's URL-based navigation. Share links generate URLs that open directly to content screens.
The biggest benefit I've found with Expo Router in a template/starter kit context is that new developers understand the navigation immediately. They look at the file tree and they see the navigation structure. There's no "where is navigation defined?" confusion. The file system is the source of truth.
If you're building a React Native app and want Expo Router already set up with tabs, auth, deep linking, and a Convex backend, Ship React Native gets you past the boilerplate. But even if you're starting from scratch, I hope this guide gives you everything you need to set up Expo Router yourself. The file-based approach is genuinely better for most apps, and once you've used it, going back to manual navigator configuration feels like unnecessary work.
Related posts:
- Convex for React Native: Why I Switched from Supabase -- the backend that pairs with Expo Router
- How to Build an AI-Powered React Native App -- building features on top of your navigation
- Best React Native Starter Kits in 2026 -- templates that come with Expo Router pre-configured