Expo Router Deep Dive: File-Based Navigation for React Native
Mobile14 min read

Expo Router Deep Dive: File-Based Navigation for React Native

A complete guide to Expo Router: file-based routing, Stack/Tabs/Drawer layouts, dynamic and catch-all routes, route groups, typed routes, deep linking, protected auth flows, modals, and migrating from React Navigation.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

Mar 18, 2026
#Expo#Expo Router#React Native#Navigation#TypeScript#Deep Linking#Mobile Development

Expo Router is a file-based routing library for React Native and Expo apps that turns the project file system into the navigation tree: every file inside the app directory automatically becomes a screen with its own URL. Built on top of React Navigation, it replaces imperative navigator configuration with conventions - _layout.tsx files declare Stack, Tabs, or Drawer navigators, [id].tsx creates dynamic routes, and folders wrapped in parentheses like (tabs) group routes without affecting URLs. Because every screen is addressable by a URL, Expo Router gives you deep linking, universal links, and web support from the same codebase with almost no extra configuration. It also supports typed routes, which turn navigation strings into compile-time checked TypeScript types. If you have built a Next.js app, the mental model feels instantly familiar, but Expo Router is purpose-built for native navigation on iOS and Android.

In this deep dive we will go from first principles to production patterns: how file-based routing actually works, how to compose Stack, Tabs, and Drawer layouts, how to handle dynamic and catch-all routes, route groups, typed routes, params, deep linking, protected auth flows, modals, and finally how to migrate an existing React Navigation codebase.

How Does File-Based Routing Work?

The core idea is simple: every file inside the app directory becomes a route, and the path to that file becomes the URL. There is no central navigator configuration to keep in sync with your screens. If you create app/settings.tsx, you automatically get a /settings route. This makes your navigation self-documenting and eliminates an entire class of bugs where a screen exists but was never registered in a navigator.

Expo Router maps files to routes with a handful of conventions:

  • app/index.tsx maps to the root route (/)
  • app/profile.tsx maps to /profile
  • app/settings/notifications.tsx maps to /settings/notifications
  • app/[id].tsx is a dynamic route matching /123, /abc, etc.
  • app/_layout.tsx defines the navigator that wraps its sibling routes
  • Files or folders wrapped in parentheses like (tabs) are route groups that do not appear in the URL

Because routes are resolved from the filesystem, every screen in your app is addressable by a URL. That single design decision is what unlocks first-class deep linking, universal links, and even web support with the same code.

The app Directory Structure

A typical Expo Router project keeps all routable screens in the app directory and everything else (components, hooks, services, types) outside of it. Here is a realistic layout for an app with authentication, a tab bar, and detail screens.

bash
app/
  _layout.tsx            # Root layout (Stack) - decides auth vs app
  (auth)/
    _layout.tsx          # Stack for the auth flow
    login.tsx            # /login
    register.tsx         # /register
  (tabs)/
    _layout.tsx          # Tabs navigator
    index.tsx            # / (Home tab)
    explore.tsx          # /explore
    profile.tsx          # /profile
  posts/
    [id].tsx             # /posts/:id  (dynamic)
    index.tsx            # /posts
  [...missing].tsx       # Catch-all 404
  modal.tsx             # A modal screen

src/
  components/            # Reusable UI, outside app/ so it is not routed
  services/             # API clients, auth store
  types/                # Shared TypeScript types

Keep only routable screens inside app. Components, services, and types belong outside it so Expo Router does not try to turn a Button component into a screen.

Layouts with _layout.tsx: Stack, Tabs, and Drawer

A _layout.tsx file declares the navigator that wraps every route in its directory. This is where Expo Router meets React Navigation. Instead of registering screens by hand, you render a navigator and let the file system supply the screens. Individual screen options are configured with the Screen component, keyed by name.

Stack Layout

tsx
// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack screenOptions={{ headerShown: true }}>
      <Stack.Screen name='(tabs)' options={{ headerShown: false }} />
      <Stack.Screen
        name='posts/[id]'
        options={{ title: 'Post Details' }}
      />
      <Stack.Screen
        name='modal'
        options={{ presentation: 'modal', title: 'New Post' }}
      />
    </Stack>
  );
}

Tabs Layout

Nesting a Tabs navigator inside a route group keeps the tab bar out of the URL while giving each tab its own file. Notice how each Tabs.Screen name matches a file inside the (tabs) folder.

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

export default function TabsLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#2563eb',
        headerShown: true
      }}
    >
      <Tabs.Screen
        name='index'
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name='home' color={color} size={size} />
          )
        }}
      />
      <Tabs.Screen
        name='explore'
        options={{
          title: 'Explore',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name='search' color={color} size={size} />
          )
        }}
      />
      <Tabs.Screen
        name='profile'
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name='person' color={color} size={size} />
          )
        }}
      />
    </Tabs>
  );
}

Drawer Layout

The Drawer navigator ships in a separate package. After installing expo-router along with @react-navigation/drawer and react-native-gesture-handler, you compose it exactly like the others.

tsx
// app/_layout.tsx
import 'react-native-gesture-handler';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Drawer } from 'expo-router/drawer';

export default function RootLayout() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <Drawer>
        <Drawer.Screen
          name='index'
          options={{ drawerLabel: 'Home', title: 'Home' }}
        />
        <Drawer.Screen
          name='settings'
          options={{ drawerLabel: 'Settings', title: 'Settings' }}
        />
      </Drawer>
    </GestureHandlerRootView>
  );
}

How Do Dynamic and Catch-All Routes Work?

Square brackets in a filename create a dynamic segment. app/posts/[id].tsx matches any /posts/:id path and exposes the id parameter to the screen. For matching an arbitrary number of segments, use the rest syntax [...name].tsx, which is perfect for 404 pages or documentation-style nested paths.

tsx
// app/posts/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { Text, View } from 'react-native';

export default function PostScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();

  return (
    <View style={{ padding: 16 }}>
      <Text>Viewing post {id}</Text>
    </View>
  );
}
tsx
// app/[...missing].tsx - catches every unmatched route
import { Link } from 'expo-router';
import { Text, View } from 'react-native';

export default function NotFound() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>This screen does not exist.</Text>
      <Link href='/'>Go back home</Link>
    </View>
  );
}

What Are Route Groups Like (auth) and (tabs)?

A folder wrapped in parentheses is a route group. It lets you organise files and attach a shared layout without adding a segment to the URL. So (tabs)/index.tsx is still served at / and (auth)/login.tsx is served at /login. Groups are the idiomatic way to separate an authenticated area from an unauthenticated one while keeping clean URLs.

Route groups are purely organisational. Their parentheses never appear in a link, so you get logical folders and a shared _layout without leaking implementation details into your URLs.

What Are Typed Routes?

Expo Router can generate TypeScript types for every route in your app, turning href strings into a statically checked union. Enable it once in app.json and Expo Router will produce declarations so that navigating to a route that does not exist becomes a compile-time error rather than a runtime crash.

typescript
// app.json
{
  'expo': {
    'experiments': {
      'typedRoutes': true
    }
  }
}
tsx
import { Link, useRouter } from 'expo-router';

// Valid - matches app/posts/[id].tsx
<Link href={{ pathname: '/posts/[id]', params: { id: '42' } }}>
  Open post
</Link>;

// With typed routes on, this is a TypeScript error:
// Argument of type '/psots/42' is not assignable to Href
const router = useRouter();
router.push('/psots/42'); // typo caught at compile time

How Do You Navigate with Link and useRouter?

There are two ways to navigate. Link is a declarative component that renders like an anchor and is ideal for user-triggered navigation such as list items and buttons. useRouter gives you an imperative router object for programmatic navigation from event handlers, effects, or after an async operation completes.

tsx
import { Link, useRouter } from 'expo-router';
import { Button, Pressable, Text } from 'react-native';

export default function Home() {
  const router = useRouter();

  return (
    <>
      {/* Declarative navigation */}
      <Link href='/explore' asChild>
        <Pressable>
          <Text>Go to Explore</Text>
        </Pressable>
      </Link>

      {/* Imperative navigation */}
      <Button title='Open settings' onPress={() => router.push('/settings')} />

      {/* Replace prevents going back to this screen */}
      <Button title='Log out' onPress={() => router.replace('/login')} />

      {/* Go back in the stack */}
      <Button title='Back' onPress={() => router.back()} />
    </>
  );
}

The router exposes the navigation primitives you will reach for most:

  • router.push - pushes a new screen onto the stack
  • router.replace - swaps the current screen, removing it from history
  • router.back - pops the current screen
  • router.navigate - navigates, reusing an existing screen in the stack if possible
  • router.setParams - updates the current route params without navigating

How Do You Pass Params with useLocalSearchParams?

Params travel through the URL. You can pass them via the params object on a Link or router call, and read them with useLocalSearchParams. Because they are serialised into the route, keep them small and primitive; pass an id and refetch the full object rather than serialising an entire record.

tsx
import { Link, useRouter } from 'expo-router';

// Passing params declaratively
<Link
  href={{ pathname: '/posts/[id]', params: { id: post.id, ref: 'feed' } }}
>
  {post.title}
</Link>;

// Passing params imperatively
const router = useRouter();
router.push({ pathname: '/posts/[id]', params: { id: post.id } });
tsx
// app/posts/[id].tsx
import { useLocalSearchParams } from 'expo-router';

export default function Post() {
  // id comes from the path, ref from the query string
  const { id, ref } = useLocalSearchParams<{ id: string; ref?: string }>();
  // Fetch the full record by id instead of passing the whole object
  // const { data } = usePost(id);
  return null;
}

Prefer useLocalSearchParams over useGlobalSearchParams in most screens. The local variant only re-renders your screen for its own params, avoiding surprise re-renders when unrelated routes change.

Nested Navigators

Real apps nest navigators: a root Stack that contains a Tabs navigator, where one tab itself contains a Stack for drill-down navigation. Expo Router models this naturally because each folder can have its own _layout.tsx. The root Stack renders the (tabs) group as a single screen; inside it the Tabs layout renders each tab; and a tab folder can nest yet another Stack for its detail screens.

bash
app/
  _layout.tsx            # Root Stack
  (tabs)/
    _layout.tsx          # Tabs
    index.tsx            # Home tab
    feed/
      _layout.tsx        # Stack inside the Feed tab
      index.tsx          # /feed
      [id].tsx           # /feed/:id pushed on top, tab bar stays visible

With this structure, pushing /feed/42 keeps the tab bar visible while stacking the detail screen inside the Feed tab, exactly the behaviour users expect from native apps.

How Does Deep Linking Work in Expo Router?

Because every screen already has a URL, deep linking is largely automatic. You only need to declare your scheme and, for universal links, associate your domain. Set the scheme in app.json and Expo Router will route incoming links like myapp://posts/42 to the correct screen with no extra linking configuration.

typescript
// app.json
{
  'expo': {
    'scheme': 'myapp',
    'ios': {
      'associatedDomains': ['applinks:tahakocal.dev']
    },
    'android': {
      'intentFilters': [
        {
          'action': 'VIEW',
          'autoVerify': true,
          'data': [{ 'scheme': 'https', 'host': 'tahakocal.dev' }],
          'category': ['BROWSABLE', 'DEFAULT']
        }
      ]
    }
  }
}

With associated domains configured and the matching apple-app-site-association and assetlinks.json files hosted on your domain, tapping https://tahakocal.dev/posts/42 opens your app straight to that post. During development you can test any link with npx uri-scheme open myapp://posts/42 --ios.

How Do You Protect Routes Behind Authentication?

A robust auth pattern uses a root layout that decides which group to render based on session state. Modern Expo Router versions expose Stack.Protected, letting you guard entire route groups declaratively with a guard prop. When the guard is false, its routes are removed from the navigator and any attempt to reach them redirects away automatically.

tsx
// app/_layout.tsx
import { Stack } from 'expo-router';
import { useAuth } from '../src/services/auth';

export default function RootLayout() {
  const { isAuthenticated, isLoading } = useAuth();

  if (isLoading) {
    return null; // keep the splash screen visible while restoring session
  }

  return (
    <Stack screenOptions={{ headerShown: false }}>
      {/* Only mounted when signed in */}
      <Stack.Protected guard={isAuthenticated}>
        <Stack.Screen name='(tabs)' />
      </Stack.Protected>

      {/* Only mounted when signed out */}
      <Stack.Protected guard={!isAuthenticated}>
        <Stack.Screen name='(auth)' />
      </Stack.Protected>
    </Stack>
  );
}

If you are on a version without Stack.Protected, the classic approach is to redirect imperatively from a layout using the Redirect component or a router.replace inside an effect. The principle is the same: read session state high in the tree and send unauthenticated users to the auth group.

tsx
// app/(tabs)/_layout.tsx - guard a group with Redirect
import { Redirect, Tabs } from 'expo-router';
import { useAuth } from '../../src/services/auth';

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

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

  return <Tabs />;
}

Modals

A modal is just a regular screen presented differently. Register it in a Stack layout with presentation set to modal and it slides up over the current screen on iOS, dimming the content behind it. Open it like any other route and dismiss it with router.back or a Link that points back to the underlying screen.

tsx
// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name='(tabs)' options={{ headerShown: false }} />
      <Stack.Screen
        name='modal'
        options={{ presentation: 'modal', title: 'Compose' }}
      />
    </Stack>
  );
}
tsx
// app/modal.tsx
import { useRouter } from 'expo-router';
import { Button, Text, View } from 'react-native';

export default function ComposeModal() {
  const router = useRouter();
  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Text>Compose a new post</Text>
      <Button title='Close' onPress={() => router.back()} />
    </View>
  );
}

// Open it from anywhere: router.push('/modal')

How Do You Migrate from React Navigation?

Since Expo Router is built on top of React Navigation, migration is evolutionary rather than a rewrite. Your screen components largely stay the same; what changes is how they are registered and how you navigate between them.

The key mental shifts when migrating:

  • Replace createNativeStackNavigator and manual Screen registration with an app directory and _layout.tsx files
  • Move each screen component into a file whose path equals its route
  • Swap navigation.navigate(Screen, params) for router.push({ pathname, params }) or a Link
  • Replace useNavigation and useRoute with useRouter and useLocalSearchParams
  • Delete your central linking config - URLs now come from the file structure
  • Wrap the app once with the root _layout instead of NavigationContainer
tsx
// Before - React Navigation
function ProfileScreen({ navigation, route }) {
  const { userId } = route.params;
  return (
    <Button
      title='Edit'
      onPress={() => navigation.navigate('EditProfile', { userId })}
    />
  );
}

// After - Expo Router (app/profile/[userId].tsx)
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Button } from 'react-native';

export default function ProfileScreen() {
  const { userId } = useLocalSearchParams<{ userId: string }>();
  const router = useRouter();
  return (
    <Button
      title='Edit'
      onPress={() =>
        router.push({ pathname: '/profile/edit', params: { userId } })
      }
    />
  );
}

You can even migrate incrementally: keep an existing React Navigation stack inside a single Expo Router screen while you move the rest of the app over folder by folder. Because both share the same underlying library, they interoperate cleanly.

Key Takeaways

What to remember about Expo Router:

  • The file system is your navigation tree - every file in app becomes an addressable route
  • _layout.tsx defines the navigator (Stack, Tabs, or Drawer) for its directory, and navigators nest naturally through nested folders
  • Dynamic routes use [id].tsx and catch-all routes use [...name].tsx for flexible matching
  • Route groups like (auth) and (tabs) organise files and share layouts without polluting URLs
  • Enable typed routes to catch broken links at compile time instead of runtime
  • Use Link for declarative navigation and useRouter for imperative navigation, reading params with useLocalSearchParams
  • Deep linking and universal links are almost free because every screen already has a URL
  • Guard authenticated areas with Stack.Protected or a Redirect in a layout, and treat modals as regular screens with presentation: modal
  • Migrating from React Navigation is incremental since Expo Router is built on top of it

Expo Router turns navigation from a configuration chore into a natural consequence of how you organise your files. Once the conventions click, you spend less time wiring navigators and more time building features - with deep linking, typed safety, and web support coming along for the ride.

Share this article

Related Articles