MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Native Expo Router: File-Based Routing for Mobile

Use Expo Router: file-based navigation, deep linking, and shared routes.

React NativeExpo RouterMobileRouting

By MinhVo

Introduction

Navigation in React Native has historically been one of the most complex aspects of mobile development, requiring developers to manually configure navigators, define route maps, and manage navigation state across deeply nested component trees. Expo Router fundamentally simplifies this by bringing the file-system routing paradigm — popularized by Next.js in the web world — directly to React Native applications.

With Expo Router, your file structure is your routing configuration. Create a file in the app/ directory, and it becomes a navigable screen automatically. This approach eliminates boilerplate, enforces consistent patterns, and provides built-in deep linking that works out of the box. In this guide, we'll explore every aspect of Expo Router from basic setup through advanced patterns including shared routes, authentication guards, and platform-specific navigation.

Mobile Navigation Design

Understanding Expo Router: Core Concepts and Architecture

Expo Router is built on top of React Navigation, the most widely-used navigation library in React Native. Rather than replacing React Navigation, Expo Router provides a higher-level abstraction that generates React Navigation configuration from your file system. This means you get the reliability and performance of React Navigation with the developer experience of file-based routing.

How File-System Routing Works

When your app starts, Expo Router scans the app/ directory and builds a route tree based on file names and directory structure. Each file exports a React component that becomes a screen. Directories create nested navigators, and special naming conventions control the type of navigator created.

The routing conventions follow established patterns:

  • app/index.tsx — The initial route for a directory
  • app/about.tsx — A screen at /about
  • app/(tabs)/index.tsx — A tab navigator group (parentheses create groups)
  • app/[id].tsx — A dynamic route with a parameter named id
  • app/[...slug].tsx — A catch-all route for nested paths
  • app/_layout.tsx — A layout component wrapping all routes in that directory

The Layout System

Layouts are the backbone of Expo Router's navigation architecture. Every directory can contain a _layout.tsx file that defines how child routes are presented. The root app/_layout.tsx is your app's entry point and typically defines the outermost navigator.

// app/_layout.tsx
import { Stack } from 'expo-router';
 
export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Home' }} />
      <Stack.Screen name="about" options={{ title: 'About' }} />
    </Stack>
  );
}

Deep Linking Architecture

Expo Router generates deep link configuration automatically from your file structure. If you have a file at app/products/[id].tsx, the deep link yourapp://products/123 automatically routes to that screen with id set to "123". This works across iOS Universal Links, Android App Links, and web URLs without any additional configuration.

File System Architecture

Architecture and Design Patterns

Tab Navigation Pattern

The most common mobile navigation pattern uses tabs at the bottom of the screen. Expo Router handles this through the (tabs) directory convention:

app/
├── _layout.tsx          # Root layout (Stack)
├── index.tsx            # Landing/redirect
└── (tabs)/
    ├── _layout.tsx      # Tab navigator layout
    ├── index.tsx        # Home tab
    ├── search.tsx       # Search tab
    ├── cart.tsx         # Cart tab
    └── profile.tsx      # Profile tab

The tabs layout file configures the tab bar:

// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
 
export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#3b82f6',
        tabBarInactiveTintColor: '#6b7280',
        tabBarStyle: {
          backgroundColor: '#ffffff',
          borderTopWidth: 1,
          borderTopColor: '#e5e7eb',
          paddingBottom: 8,
          paddingTop: 8,
          height: 60,
        },
        headerStyle: { backgroundColor: '#f8fafc' },
        headerTitleStyle: { fontWeight: '600' },
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home-outline" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Search',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="search-outline" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="cart"
        options={{
          title: 'Cart',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="cart-outline" size={size} color={color} />
          ),
          tabBarBadge: 3,
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person-outline" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

Stack Navigation Within Tabs

Each tab can maintain its own navigation stack, allowing users to drill into detail screens while preserving tab state:

app/(tabs)/
├── _layout.tsx
├── index.tsx
└── products/
    ├── _layout.tsx     # Stack navigator for products tab
    ├── index.tsx       # Product list
    └── [id].tsx        # Product detail (dynamic route)

Group Routes and Conditional Navigation

Route groups using parentheses let you organize screens without affecting the URL structure. This is powerful for authentication flows:

app/
├── _layout.tsx
├── (auth)/
│   ├── _layout.tsx     # Auth layout (no header)
│   ├── login.tsx
│   └── register.tsx
├── (app)/
│   ├── _layout.tsx     # Main app layout (tabs)
│   └── (tabs)/
│       ├── _layout.tsx
│       └── index.tsx
└── modal.tsx           # Modal screen accessible from anywhere

Step-by-Step Implementation

Installation and Setup

Create a new Expo project with the router template:

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

Or add Expo Router to an existing project:

npx expo install expo-router expo-linking expo-constants expo-status-bar

Update your package.json entry point:

{
  "main": "expo-router/entry"
}

Configure the scheme in app.json:

{
  "expo": {
    "scheme": "myapp",
    "plugins": ["expo-router"]
  }
}

Creating Your First Route

The simplest Expo Router app needs just two files:

// app/_layout.tsx
import { Stack } from 'expo-router';
 
export default function RootLayout() {
  return <Stack />;
}
// app/index.tsx
import { View, Text, StyleSheet } from 'react-native';
import { Link } from 'expo-router';
 
export default function Home() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Welcome Home</Text>
      <Link href="/about" style={styles.link}>
        Go to About
      </Link>
    </View>
  );
}
 
const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 16,
  },
  link: {
    fontSize: 18,
    color: '#3b82f6',
    textDecorationLine: 'underline',
  },
});

Implementing Dynamic Routes

Dynamic routes use bracket notation in filenames. The parameter value is extracted automatically:

// app/products/[id].tsx
import { View, Text, StyleSheet } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
 
export default function ProductDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();
 
  return (
    <View style={styles.container}>
      <Stack.Screen options={{ title: `Product ${id}` }} />
      <Text style={styles.title}>Product #{id}</Text>
      <Text style={styles.description}>
        This is the detail page for product {id}. The ID was extracted
        automatically from the URL path.
      </Text>
    </View>
  );
}
 
const styles = StyleSheet.create({
  container: { flex: 1, padding: 16, backgroundColor: '#fff' },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 12 },
  description: { fontSize: 16, lineHeight: 24, color: '#4b5563' },
});

Programmatic Navigation

Use the useRouter hook for imperative navigation:

import { useRouter } from 'expo-router';
import { Button } from 'react-native';
 
export default function CheckoutButton() {
  const router = useRouter();
 
  const handleCheckout = () => {
    // Navigate to a specific product
    router.push('/products/42');
 
    // Replace current screen (no back button)
    router.replace('/order-confirmation');
 
    // Navigate with query params
    router.push({
      pathname: '/search',
      params: { q: 'running shoes', category: 'footwear' },
    });
 
    // Go back
    router.back();
  };
 
  return <Button title="Checkout" onPress={handleCheckout} />;
}

App Navigation Flow

Real-World Use Cases

Use Case 1: Authentication Flow with Route Guards

A common pattern requires redirecting unauthenticated users to login:

// app/_layout.tsx
import { Stack, useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';
import { useAuth } from '../hooks/useAuth';
 
export default function RootLayout() {
  const { isAuthenticated, isLoading } = useAuth();
  const segments = useSegments();
  const router = useRouter();
 
  useEffect(() => {
    if (isLoading) return;
 
    const inAuthGroup = segments[0] === '(auth)';
 
    if (!isAuthenticated && !inAuthGroup) {
      router.replace('/(auth)/login');
    } else if (isAuthenticated && inAuthGroup) {
      router.replace('/(tabs)');
    }
  }, [isAuthenticated, segments, isLoading]);
 
  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Screen name="(auth)" />
      <Stack.Screen name="(tabs)" />
      <Stack.Screen
        name="modal"
        options={{ presentation: 'modal', headerShown: true }}
      />
    </Stack>
  );
}

Use Case 2: Shared Routes for Parallel Navigation

Display different content on the same URL depending on context (e.g., tablet vs phone):

app/
├── (home)/
│   ├── _layout.tsx
│   ├── index.tsx           # Phone: single column
│   └── details/[id].tsx    # Phone: detail replaces list
├── (home+tablet)/
│   ├── _layout.tsx         # Tablet: split view
│   ├── index.tsx           # Tablet: list pane
│   └── details/[id].tsx    # Tablet: detail pane

Use Case 3: Modal and Sheet Screens

Present screens as modals or bottom sheets by configuring screen options in the layout:

// 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"
        options={{
          presentation: 'modal',
          title: 'Settings',
          headerLeft: () => <CloseButton />,
        }}
      />
      <Stack.Screen
        name="share"
        options={{
          presentation: 'formSheet',
          title: 'Share',
        }}
      />
    </Stack>
  );
}

Best Practices for Production

  1. Use typed routes for type safety: Enable typed routes in your app.json with "experiments": { "typedRoutes": true } to get autocompletion and compile-time validation of all navigation calls.

  2. Organize with route groups: Use parenthesized directory names like (auth), (tabs), and (modals) to organize related screens without affecting URL paths.

  3. Implement loading and error states at the layout level: Use the _layout.tsx files to define loading indicators and error boundaries that apply to all child routes.

  4. Optimize with route preloading: Use router.prefetch('/products/[id]') to preload screens that users are likely to navigate to, reducing perceived load times.

  5. Configure deep linking for all platforms: Set up iOS Universal Links and Android App Links by configuring your app.json with proper associated domains and intent filters.

  6. Use useNavigation for dynamic header configuration: Update screen options dynamically using navigation.setOptions() within screen components rather than hardcoding everything in layouts.

  7. Keep layout files focused: Layout files should only contain navigator configuration and shared UI elements. Screen-specific logic belongs in the screen files.

  8. Implement proper back navigation handling: On Android, the hardware back button follows the navigation stack by default. For custom back behavior, use useNavigation().addListener('beforeRemove', ...).

Common Pitfalls and Solutions

PitfallImpactSolution
Forgetting _layout.tsx in a directoryRoutes render without navigator wrapperAlways create a layout file when adding a new directory
Circular imports between layout and screen filesApp crashes on startup with confusing errorsExtract shared components into a separate components/ directory
Dynamic route params lost on deep linkParams are undefined when app opens from a deep linkUse useGlobalSearchParams() instead of useLocalSearchParams() for params that span layouts
Tab state lost when navigating between tabsEach tab remounts instead of preserving stateEnsure tab layout uses Tabs navigator, not nested Stack navigators
Modal dismisses instead of going backrouter.back() closes the modal instead of navigating within itUse router.canGoBack() and check navigation state before calling back()
Type errors with dynamic route paramsTypeScript errors when accessing params.idUse generic type parameter: useLocalSearchParams<{ id: string }>()

Performance Optimization

Route Preloading and Suspense

Preload screens that users are likely to visit to eliminate navigation delays:

import { useRouter } from 'expo-router';
import { useEffect } from 'react';
 
export default function ProductList() {
  const router = useRouter();
 
  useEffect(() => {
    // Preload the detail screen when the list renders
    router.prefetch('/products/[id]');
  }, []);
 
  return <ProductListContent />;
}

Layout Optimization

Avoid unnecessary re-renders in layout files by memoizing expensive computations:

import { Stack } from 'expo-router';
import { useMemo } from 'react';
 
export default function RootLayout() {
  const screenOptions = useMemo(
    () => ({
      headerStyle: { backgroundColor: '#f8fafc' },
      headerTintColor: '#1e293b',
      headerTitleStyle: { fontWeight: '600' as const },
    }),
    []
  );
 
  return <Stack screenOptions={screenOptions} />;
}

Bundle Splitting

Expo Router automatically code-splits at the route level, loading only the JavaScript bundle for screens that are currently mounted. This dramatically reduces initial load time for apps with many screens.

Comparison with Alternatives

FeatureExpo RouterReact NavigationReact Native Navigation (Wix)
Routing ModelFile-system basedProgrammatic/Config-basedProgrammatic/Config-based
Deep LinkingAutomatic from file structureManual configurationManual configuration
Web SupportFirst-class (SSR compatible)LimitedNot supported
Type SafetyBuilt-in typed routesManual typing with paramsManual typing
Native FeelStandard native navigationStandard native navigationTruly native navigation controllers
Bundle SplittingAutomaticManual (lazy loading)Manual
Learning CurveLow (familiar from web)MediumHigh
Layout SystemFile-based layoutsNavigator compositionNavigation layout API

Advanced Patterns and Techniques

Catch-All Routes for Error Handling

Use [...slug].tsx to catch all unmatched routes and display custom error screens:

// app/[...unmatched].tsx
import { View, Text, StyleSheet } from 'react-native';
import { Link, useLocalSearchParams } from 'expo-router';
 
export default function NotFound() {
  const { unmatched } = useLocalSearchParams<{ unmatched: string[] }>();
 
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Page Not Found</Text>
      <Text style={styles.path}>/{unmatched?.join('/')}</Text>
      <Link href="/" style={styles.link}>Return Home</Link>
    </View>
  );
}
 
const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
  path: { fontSize: 16, color: '#6b7280', marginBottom: 24 },
  link: { fontSize: 18, color: '#3b82f6' },
});

Imperative Linking API

Generate deep links programmatically for sharing or notifications:

import { createURL } from 'expo-linking';
 
const productUrl = createURL(`/products/123`, {
  scheme: 'myapp',
  queryParams: { referral: 'email-campaign' },
});
// Result: myapp://products/123?referral=email-campaign

Testing Strategies

Test Expo Router screens using the renderRouter testing utility:

import { renderRouter, screen } from 'expo-router/testing-library';
import { fireEvent } from '@testing-library/react-native';
 
describe('Product Navigation', () => {
  it('should navigate to product detail on press', async () => {
    renderRouter({
      '(tabs)/index': () => <ProductList />,
      '(tabs)/products/[id]': () => <ProductDetail />,
    });
 
    const productCard = await screen.findByText('Running Shoes');
    fireEvent.press(productCard);
 
    expect(screen).toHavePathname('/products/1');
  });
});

Nested Layouts and Route Groups

Expo Router supports nested layouts through directory-based conventions. A _layout.tsx file in a directory defines the layout component for all routes within that directory. This enables patterns like tab navigation where each tab has its own stack, or drawer navigation where certain screens share a common sidebar. The layout component renders its children through the <Slot /> component, which represents the matched child route.

Route groups use parentheses in directory names to organize routes without affecting the URL path. A directory named (auth) contains authentication-related screens like login and register, while (app) contains the main application screens. Both groups can have their own layouts, allowing the auth flow to use a simple stack navigator while the main app uses tab navigation. The parentheses are stripped from the URL, so /login maps to (auth)/login.tsx without the group prefix appearing in the address bar.

Parallel routes enable rendering multiple screens simultaneously in the same layout. Use the @ prefix for named slots that can be populated independently. For example, @modal creates a slot that renders as an overlay while the main content continues to display underneath. This pattern is valuable for detail views, confirmation dialogs, and multi-pane layouts on tablets. The named slot receives its own navigation state, allowing independent history management for each parallel route.

Future Outlook

Expo Router continues to evolve with planned improvements including server-side rendering for React Native web targets, enhanced static site generation for marketing pages, and tighter integration with EAS for deployment workflows. The convergence of web and native routing paradigms makes Expo Router a compelling choice for teams building cross-platform applications that share navigation logic between web and mobile.

Conclusion

Expo Router brings the developer experience of web-based file-system routing to React Native, dramatically reducing the boilerplate and configuration complexity traditionally associated with mobile navigation. By leveraging your file structure as the routing configuration, it enforces consistent patterns, provides automatic deep linking, and enables features like code splitting without additional setup.

Key takeaways:

  1. File structure IS your routing configuration — create a file, get a route
  2. Built on React Navigation — battle-tested reliability with modern DX
  3. Automatic deep linking — works across iOS Universal Links, Android App Links, and web
  4. Layout system prevents boilerplate — shared headers, auth guards, and tab bars in one file
  5. Typed routes catch errors at compile time — no more runtime navigation bugs
  6. Web-first architecture — SSR-compatible with automatic code splitting
  7. Group routes organize without URL impact — clean separation of auth, app, and modal flows

Start building with npx create-expo-app@latest my-app --template tabs and experience the simplicity of file-system routing for React Native.