Back to Blog
React Native
21 min read

Expo Router in 2026: Tabs, Navigation, and Why I Switched from React Navigation

Paweł Karniej·March 2026

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?
  • Setting Up Tabs with Expo Router
  • Stack Navigation Inside Tabs
  • Expo Router vs React Navigation
  • Authentication Flow with Expo Router
  • How to Navigate Using Expo Router
  • Deep Linking
  • How to Structure an Expo Router Project
  • Common Patterns
  • When NOT to Use Expo Router
  • How I Use Expo Router in Ship React Native
  • 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: