DEV Community

Cover image for React Navigation vs Expo Router: Which One Should You Actually Use?
Kishan Agarwal
Kishan Agarwal

Posted on • Edited on • Originally published at cosmoscribe.hashnode.dev

React Navigation vs Expo Router: Which One Should You Actually Use?

Ok, before we go into the depths of these concepts, I want to tell you that we will take it easy. I don't want you to get overwhelmed by the two names sitting in the title. By the end of this, you will know exactly what each one does, why one exists because of the other, and most importantly, which one you should reach for on your next project.

Navigation sounds like a simple thing. Click a button, go to a screen. But the moment your app has auth flows, nested tabs, shared headers, and deep links, "click a button, go to a screen" becomes a surprisingly gnarly engineering problem.

Let's break it down.

What Is Navigation, Really?

In web development, navigation is tied to URLs. Different URL, different page. The browser handles it.

Mobile apps don't have URLs. They have screens. And you, the developer, are responsible for managing which screen is visible, what data it carries, and what happens when the user presses the hardware back button on Android.

Navigation in a mobile app is the system that controls which screen is on the user's display, what state that screen holds, and how the app transitions between screens.

Let me explain it in simple words.

Think of your mobile app as a stack of physical cards. When you open the app, Card 1 (the home screen) is on top. You tap a button, and Card 2 (the profile screen) slides on top. You press back, Card 2 slides off, and Card 1 is visible again. The stack is your navigation state.

Now imagine managing that stack of cards manually — in code — every time you add a new screen, a new flow, or a new type of transition. That's what navigation management is. And that's the problem both React Navigation and Expo Router exist to solve.

Before Either of Them: Doing It Yourself

Before reaching for a library, let me show you what navigation looks like if you build it from scratch. Not because you should — you absolutely shouldn't — but because seeing the raw version makes everything else click.

// navigation/NavigationContext.js
import { createContext, useContext, useState } from 'react';

const NavigationContext = createContext(null);

export function NavigationProvider({ children }) {
  const [currentScreen, setCurrentScreen] = useState('Home');

  return (
    <NavigationContext.Provider value={{ currentScreen, setCurrentScreen }}>
      {children}
    </NavigationContext.Provider>
  );
}

export const useNavigation = () => useContext(NavigationContext);

You create a Context that holds the current screen name. Then, in your root component, you conditionally render screens based on that value.

// App.js
import { NavigationProvider, useNavigation } from './navigation/NavigationContext';
import HomeScreen from './screens/HomeScreen';
import ProfileScreen from './screens/ProfileScreen';

function AppContent() {
  const { currentScreen } = useNavigation();

  if (currentScreen === 'Home') return <HomeScreen />;
  if (currentScreen === 'Profile') return <ProfileScreen />;
  return null;
}

export default function App() {
  return (
    <NavigationProvider>
      <AppContent />
    </NavigationProvider>
  );
}

And navigating is just calling setCurrentScreen('Profile') from anywhere inside the tree.

This works. For exactly two screens. The moment you need transitions, back-button handling, passing parameters between screens, nested navigators, or deep links, this falls apart completely. You'd end up rebuilding half of React Navigation from scratch, worse.

This is only for education. In production, you reach for the real tools.

React Navigation: The Long-Time Standard

React Navigation has been the standard for React Native routing since 2017. If you have worked on any non-trivial React Native app in the last five years, you have used it.

React Navigation is a JavaScript-based routing library for React Native that provides navigators — Stack, Tab, Drawer — which you compose together to build your app's navigation structure.

The mental model is simple: you define a navigator, register your screens inside it, and React Navigation handles the transitions, the back stack, and the gestures.

// App.js (React Navigation setup)
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from './screens/HomeScreen';
import ProfileScreen from './screens/ProfileScreen';
import SettingsScreen from './screens/SettingsScreen';

const Stack = createNativeStackNavigator();

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator initialRouteName="Home">
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Profile" component={ProfileScreen} />
        <Stack.Screen name="Settings" component={SettingsScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

Every screen is explicitly registered. The route name is a string you type manually. If you rename "Profile" in one file but forget to update the navigate('Profile') call in another file, your app crashes at runtime. No compile-time safety, no warning. Just a crash.

React Navigation gives you six navigator types to compose:

  • Stack — screens slide in from right, back button pops them
  • Native Stack — same, but uses native platform transitions (smoother)
  • Bottom Tabs — tab bar at the bottom, each tab has its own stack
  • Drawer — slide-in menu from the side
  • Material Top Tabs — swipeable tabs at the top
  • Native Bottom Tabs — platform-native tab bar (iOS especially)

You compose these. A bottom tab navigator where each tab has its own stack navigator. A drawer that wraps a stack. Sounds flexible, right?

It is. And in large apps, it becomes a deeply nested configuration file that nobody wants to touch.


The Problem That Grows With You

For a two or three-screen app, React Navigation is perfectly comfortable. The configuration is short, the mental model is clear.

But a real production app — a dashboard, an e-commerce app, a fintech product — easily has 30, 40, 50 screens. Auth stack, main app stack, nested profile stack, settings stack, and modal stack. Each screen is registered manually. Route names are strings throughout the codebase.

Let me share a very interesting problem I faced, where initially it didn't seem hectic, but soon turned into one. I was building a food application, and what started as a simple application soon became a 20-screen app with 5 different navigators, and me trying to remember each one. It was really hectic because before starting the next day, I had to take out an hour and first go through the navigation we had already

You might be thinking, "Okay, but that's just a documentation and discipline problem, not a library problem."

Partially true. But the library's design makes it worse. String-based route names mean there's no autocomplete, no type safety without extra tooling, and no way for the editor to tell you a route doesn't exist. The configuration grows in one direction: more lines, more nesting, more cognitive load.

This is the problem Expo Router was built to solve.

Expo Router: File-Based Routing for React Native

You might be thinking, "Another routing library? What does this one do differently?"

It doesn't replace React Navigation. Expo Router is built on top of React Navigation; it uses React Navigation internally. What it changes is how you define your navigation structure.

If you have used Next.js, you already understand Expo Router. The folder structure IS the navigation. Create a file, get a screen.

app/
├── index.tsx          → "/"      (Home screen)
├── profile.tsx        → "/profile"
├── settings/
│   ├── index.tsx      → "/settings"
│   ├── account.tsx    → "/settings/account"
│   └── notifications.tsx → "/settings/notifications"
└── (tabs)/
    ├── _layout.tsx    → Tab navigator definition
    ├── feed.tsx       → "/feed" (tab)
    └── explore.tsx    → "/explore" (tab)

No registration. No string route names in a central config file. The file path is the route. You rename the file, and the route updates automatically.

Navigation between screens uses router.push('/profile') or the <Link href="/profile"> component — the same mental model as web routing. If you have ever built with Next.js, React Router DOM, or TanStack Router, you are already halfway there. Your filesystem is the source of truth for the navigation

Layouts and Nested Structures

The _layout.tsx file is where Expo Router's real power shows up.

Every folder can have a _layout.tsx that wraps all the screens inside that folder. This is how you define navigator types, shared headers, shared tab bars, and shared authentication context — without repeating yourself across screens.

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

export default function TabLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#e94560' }}>
      <Tabs.Screen
        name="feed"
        options={{
          title: "'Feed',"
          tabBarIcon: ({ color }) => <Ionicons name="home" color={color} size={24} />,
        }}
      />
      <Tabs.Screen
        name="explore"
        options={{
          title: "'Explore',"
          tabBarIcon: ({ color }) => <Ionicons name="compass" color={color} size={24} />,
        }}
      />
    </Tabs>
  );
}


Every screen inside app/(tabs)/ automatically renders inside this tab navigator. You add a new tab by creating a new file in that folder. No central config update needed.

The (tabs) parentheses in the folder name are a route group — they tell Expo Router this folder is a layout grouping, not a URL segment. app/(tabs)/feed.tsx maps to /feed, not /tabs/feed.

Protected Routes and Auth Flows

This is where the two approaches diverge most visibly.

In React Navigation, you implement auth guards yourself. The standard pattern is a conditional navigator at the root — if the user is authenticated, render the app navigator; if not, render the auth navigator.

// React Navigation auth pattern
function RootNavigator() {
  const { isAuthenticated } = useAuth();

  return (
    <NavigationContainer>
      {isAuthenticated ? <AppStack /> : <AuthStack />}
    </NavigationContainer>
  );
}

This works. But every time the auth state changes, React Navigation tears down and rebuilds the entire navigator tree. On slower devices, that teardown is visible as a flash. And the auth logic lives in the root component — not close to the screens that actually need protection.

Expo Router handles this with a <Redirect /> component and layout-level guards.

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

export default function AppLayout() {
  const { isAuthenticated } = useAuth();

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

  return <Stack />;
}

import { Stack } from 'expo-router';

const isLoggedIn = false;

export function AppLayout() {
  return (
    <Stack>
      <Stack.Protected guard={!isLoggedIn}>
        <Stack.Screen name="login" />
      </Stack.Protected>

      <Stack.Protected guard={isLoggedIn}>
        <Stack.Screen name="private" />
      </Stack.Protected>
      {/* Expo Router includes all routes by default. Adding Stack.Protected creates exceptions for these screens. */}
    </Stack>
  );
}

Any screen inside app/(app)/ is automatically protected. If the user isn't authenticated, they get redirected before the screen even renders. No conditional navigator switching, no flash, no auth logic scattered across components.

The Real Engineering Part

Here is what actually happens under the hood, and where the performance differences come from.

Bundle Behavior

Expo Router uses static analysis of your file structure to build the route manifest at compile time. React Navigation builds its route map at runtime from the JavaScript you write.

In practice, this means Expo Router apps can take advantage of automatic code splitting screens that haven't been navigated to yet, and don't need to be in the initial JS bundle. React Navigation loads everything upfront because it doesn't know at build time which screens exist.

For a 50-screen app, this difference in initial load time is measurable.

Navigation Transitions

Both use the same underlying native transition engine — React Native Screens. The transitions are identical. Expo Router doesn't improve or degrade transitions because it delegates to React Navigation's navigators, which use React Native Screens underneath.

If you see a performance difference in transitions, it's not Expo Router vs React Navigation. It's your screen component's render performance.

Type Safety

This is where Expo Router's architecture pays off cleanly.

// Expo Router — typed routes (with typed routes feature enabled)
import { router } from 'expo-router';

// TypeScript knows '/settings/account' exists — autocomplete works
router.push('/settings/account');

// TypeScript catches this at compile time — file doesn't exist
router.push('/settings/nonexistent'); // ❌ Type error

React Navigation achieves type safety through a separate RootParamList type declaration that you maintain manually. It works, but you are maintaining a parallel type definition that can go out of sync with the actual navigator.

// React Navigation — manual type registration
type RootStackParamList = {
  Home: undefined;
  Profile: { userId: string };
  Settings: undefined;
  // Miss a screen here → no type error, just missing safety
};

Deep Linking

Expo Router gives you deep links automatically. /profile/42 maps to app/profile/[id].tsx. No linking configuration object needed.

React Navigation requires a separate linking config that maps URL patterns to screen names. Another file to maintain, another place to make a mistake.

const linking = {
    prefixes: [Linking.createURL("/"), "exp://127.0.0.1:8081/--/", "foodie://"],
    config: {
      initialRouteName: "Main",
      screens: {
        Main: {
          initialRouteName: "Tabs",
          screens: {
            Tabs: {
              initialRouteName: "Home",
              screens: {
                Home: "home",
                Search: "search",
                Orders: "orders",
                Profile: "profile",
              },
            },
            Restaurant: {
              path: "restaurant/:id",
              exact: true,
            },
            Cart: "cart",
          },
        },
        Auth: {
          screens: {
            Login: "login",
            Signup: "signup",
          },
        },
      },
    },
  };

The Catch: When Expo Router Is the Wrong Choice

Expo Router is not the right tool for every situation.

  • If you are not using Expo, Expo Router is not an option. It is tightly coupled to the Expo ecosystem — Metro bundler, Expo modules, and the Expo CLI build system. Bare React Native projects without Expo cannot use it.
  • If your app has highly dynamic navigation — screens that are generated at runtime based on server configuration, drag-and-drop navigation editors, or wildly non-standard navigation patterns — the file-based model works against you. The folder structure is your contract with the router. If you need to break that contract, React Navigation gives you more room.
  • If you are adding navigation to an existing large codebase, migrating from React Navigation to Expo Router is a meaningful refactor. Every screen registration becomes a file move. Every navigate('ScreenName') becomes a router.push('/path'). For a 40-screen app, that is not a weekend task.
  • If your team has deep React Navigation expertise and zero Expo Router exposure, the DX advantage flips. Expo Router's file conventions have a learning curve. The _layout.tsxroute groups with parentheses, the +not-found.tsx convention — these take time to internalize. A team that already knows React Navigation inside out will be more productive with it, at least initially.

Production Folder Structure: What It Looks Like at Scale

Here is what a real-world app structure looks like in Expo Router — the kind you'd see in a B2C product with auth, a tabbed main experience, and a separate admin area.

app/
├── _layout.tsx                    ← Root layout (fonts,auth provider, theme)
├── +not-found.tsx                 ← 404 screen
│
├── (auth)/                        ← Auth route group (unauthenticated)
│   ├── _layout.tsx                ← Auth stack navigator
│   ├── login.tsx
│   ├── register.tsx
│   └── forgot-password.tsx
│
├── (app)/                         ← Protected app (requires auth)
│   ├── _layout.tsx                ← Auth guard + app stack
│   │
│   ├── (tabs)/                    ← Bottom tab navigator
│   │   ├── _layout.tsx
│   │   ├── feed.tsx
│   │   ├── explore.tsx
│   │   └── inbox.tsx
│   │
│   ├── profile/
│   │   ├── [id].tsx               ← Dynamic route: /profile/42
│   │   └── edit.tsx
│   │
│   ├── settings/
│   │   ├── index.tsx
│   │   ├── account.tsx
│   │   └── notifications.tsx
│   │
│   └── post/
│       ├── [id].tsx               ← /post/abc123
│       └── [id]/comments.tsx      ← /post/abc123/comments
│
└── (admin)/                       ← Role-gated admin area
    ├── _layout.tsx                ← Role check redirect
    ├── index.tsx
    └── users/
        ├── index.tsx
        └── [id].tsx

The entire navigation structure of the app is visible at a glance in the file tree. A new developer can understand the app's screen hierarchy without reading a single line of JavaScript.

The equivalent React Navigation setup would be 300+ lines across multiple navigator files.

Which Teams Choose What

  • Small teams and solo developers building new apps on Expo — Expo Router, almost always. The DX advantage is immediate, and the boilerplate reduction is real from day one.
  • Teams building on bare React Native, or teams with existing large React Navigation codebases — React Navigation. The migration cost to Expo Router is rarely worth it mid-project.
  • Enterprise teams starting fresh — this is increasingly Expo Router territory, especially since Expo's managed workflow has matured significantly. Companies that once avoided Expo because of its constraints are now using it for production apps with hundreds of thousands of users.
  • Startups moving fast — Expo Router wins on iteration speed. Adding a screen is creating a file. Renaming a route is renaming a file. Type safety is automatic.

What should you choose?

The React Native ecosystem has voted: Expo Router's download numbers have grown sharply since its stable release in 2023, and Expo now maintains the most actively downloaded React Native toolchain. React Navigation isn't going anywhere — Expo Router depends on it — but for new projects, the default choice is shifting.

If you're trying mobile app development for the first time, it's good to go by the basics at least once and then decide, and if you have already built a project, then I would suggest you go with Expo Router if you are using Expo, as it provides immense speed and good developer experience, and a happy developer is the best developer😉

DIY: See the Difference Yourself

The fastest way to feel the gap is to scaffold both and look at them side by side.

React Navigation Setup (Minimal)

npx create-expo-app@latest MyReactNavApp --template blank
cd MyReactNavApp
npx expo install @react-navigation/native @react-navigation/native-stack react-native-screens react-native-safe-area-context

Then you wire up NavigationContainer, create a stack, and register your screens. Every new screen is a manual Stack.Screen entry.

// App.js
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

const Stack = createNativeStackNavigator();

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Profile" component={ProfileScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

Every new screen means coming back to this file.

Expo Router Setup (Minimal)

npx create-expo-app@latest MyExpoRouterApp --template tabs
cd MyExpoRouterApp

The template already gives you a working tab navigator with a file-based structure. To add a screen, you create a file.

// app/profile.tsx — this file IS the route
import { View, Text } from 'react-native';

export default function ProfileScreen() {
  return (
    <View>
      <Text>Profile</Text>
    </View>
  );
}

Navigate to it from anywhere:

import { router } from 'expo-router';

router.push('/profile');

Try adding five screens to each setup. Count the files you touch. That's the DX difference, right there.

Happy Exploration!

Top comments (0)