Skip to main content

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

Published: February 23, 2026 Updated: May 8, 2026 Larry Qu 12 min read

Introduction

Expo Router brings file-based routing to React Native, similar to Next.js. Create routes by adding files to your app directory. This guide covers everything you need.


How It Works

┌─────────────────────────────────────────────────────────────┐
│                 Expo Router Concept                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  File System = Routes                                       │
│                                                             │
│  app/                          Screen                       │
│  ├── _layout.tsx              → Root layout                  │
│  ├── index.tsx                → / (Home)                    │
│  ├── about.tsx                → /about                      │
│  ├── users/                   → /users                     │
│  │   ├── _layout.tsx          → Users layout               │
│  │   ├── index.tsx            → /users                     │
│  │   └── [id].tsx            → /users/:id                 │
│  └── (stack).tsx             → Optional segment            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Basic Setup

Installation

npx create-expo-app@latest my-app
cd my-app
npx expo-router start

Configuration

// app.json
{
  "expo": {
    "scheme": "myapp",
    "web": {
      "bundler": "metro"
    }
  }
}

Creating Routes

Simple Routes

// app/index.tsx - Home screen
import { View, Text } from 'react-native';
import { Link } from 'expo-router';

export default function Home() {
  return (
    <View>
      <Text>Welcome!</Text>
      <Link href="/about">Go to About</Link>
    </View>
  );
}

// app/about.tsx - About screen
import { View, Text } from 'react-native';
import { Stack } from 'expo-router';

export default function About() {
  return (
    <View>
      <Stack.Screen options={{ title: 'About Us' }} />
      <Text>About page content</Text>
    </View>
  );
}

Layouts

// app/_layout.tsx - Root layout
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>
  );
}

// app/tabs/_layout.tsx - Tab layout
import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: 'Home' }} />
      <Tabs.Screen name="profile" options={{ title: 'Profile' }} />
    </Tabs>
  );
}

Dynamic Routes

Parameters

// app/users/[id].tsx - Dynamic route
import { useLocalSearchParams } from 'expo-router';

export default function UserProfile() {
  const { id } = useLocalSearchParams();
  
  return (
    <View>
      <Text>User ID: {id}</Text>
    </View>
  );
}

// app/posts/[...slug].tsx - Catch-all route
import { useLocalSearchParams } from 'expo-router';

export default function Post() {
  const { slug } = useLocalSearchParams();
  const slugArray = Array.isArray(slug) ? slug : [slug];
  
  return (
    <Text>Post: {slugArray.join('/')}</Text>
  );
}

Multiple Parameters

// app/users/[id]/posts/[postId].tsx
import { useLocalSearchParams } from 'expo-router';

export default function UserPost() {
  const { id, postId } = useLocalSearchParams();
  
  return (
    <Text>
      User {id}'s post: {postId}
    </Text>
  );
}

Programmatic Navigation

import { useRouter } from 'expo-router';

export function LoginButton() {
  const router = useRouter();
  
  return (
    <Button onPress={() => router.push('/dashboard')}>
      Login
    </Button>
  );
}

// Navigate with params
router.push({
  pathname: '/users/[id]',
  params: { id: '123' }
});

// Go back
router.back();

// Replace (no back navigation)
router.replace('/home');

Deep Linking

// app.json
{
  "expo": {
    "scheme": "myapp",
    "extra": {
      "eas": {
        "projectId": "..."
      }
    }
  }
}

// Deep link to screen
// myapp://users/123

API Reference

# Available hooks
hooks:
  - "useRouter" - Navigate programmatically
  - "useLocalSearchParams" - Get route params
  - "useGlobalSearchParams" - Global URL params
  - "useNavigation" - React Navigation API
  - "useSegments" - Current route segments

File Conventions

Expo Router uses special filename patterns to define navigation structure:

File Pattern Purpose Example
_layout.tsx Defines navigator for this directory level Tab bar, stack, drawer
index.tsx Default route for a directory Home screen
[param].tsx Dynamic route segment /users/123
[...slug].tsx Catch-all route, matches remaining segments /docs/api/v2/endpoints
+not-found.tsx 404 / unmatched route handler Custom error screen
+html.tsx Custom HTML template (web only) Document shell
(group)/ Route group — name doesn’t appear in URL (tabs)/, (auth)/
app/
  _layout.tsx            # Root layout (Stack)
  index.tsx              # / (Home)
  (tabs)/                # Tab group (no URL segment)
    _layout.tsx          # Tab navigator
    index.tsx            # First tab
    profile.tsx          # Second tab
  users/
    [id].tsx             # /users/:id
  docs/
    [...slug].tsx        # /docs/** (catch-all)
  +not-found.tsx         # 404 screen

Root Layout

The root _layout.tsx is the most important file — it wraps the entire application and is where providers, gesture handlers, and global context belong:

// app/_layout.tsx
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { AuthProvider } from '@/contexts/AuthContext';

export default function RootLayout() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <ThemeProvider>
        <AuthProvider>
          <Stack screenOptions={{ headerShown: false }} />
        </AuthProvider>
      </ThemeProvider>
    </GestureHandlerRootView>
  );
}

Tabs Navigation

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

export default function TabLayout() {
  const colorScheme = useColorScheme();

  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: colorScheme === 'dark' ? '#fff' : '#007AFF',
        tabBarStyle: {
          backgroundColor: colorScheme === 'dark' ? '#1c1c1e' : '#fff',
        },
        headerShown: false,
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, focused }) => (
            <Ionicons name={focused ? 'home' : 'home-outline'} size={24} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarBadge: unreadCount > 0 ? unreadCount : undefined,
          tabBarIcon: ({ color, focused }) => (
            <Ionicons name={focused ? 'person' : 'person-outline'} size={24} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

Hiding a tab from the tab bar (route still exists and is navigable):

<Tabs.Screen name="hidden-screen" options={{ href: null }} />

Stack Navigation with Headers

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

export default function ProductLayout() {
  return (
    <Stack>
      <Stack.Screen
        name="index"
        options={{ title: 'Products', headerLargeTitle: true }}
      />
      <Stack.Screen
        name="[id]"
        options={({ route }) => ({
          title: 'Product Details',
          headerBackTitle: 'Back',
          headerRight: () => <ShareButton productId={route.params.id} />,
        })}
      />
    </Stack>
  );
}

Dynamic Routes and Parameters

// app/product/[id].tsx
import { useLocalSearchParams } from 'expo-router';

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

  // id is always a string or string[] — convert explicitly
  const productId = Array.isArray(id) ? id[0] : id;
  const { data } = useProduct(productId);

  return (
    <ScrollView>
      <Text>{data?.name}</Text>
    </ScrollView>
  );
}

Catch-All Routes

// app/docs/[...slug].tsx — matches /docs/a/b/c and any nested path
import { useLocalSearchParams } from 'expo-router';

export default function DocsPage() {
  const { slug } = useLocalSearchParams<{ slug: string[] }>();

  // For /docs/api/v2/endpoints: slug = ['api', 'v2', 'endpoints']
  return <Text>{slug.join('/')}</Text>;
}

Typed Routes (Expo Router v3+)

Enable compile-time type safety for all navigation calls:

// app.json
{
  "expo": {
    "experiments": {
      "typedRoutes": true
    }
  }
}
// Now href values are typed — wrong paths produce TypeScript errors
import { Link } from 'expo-router';

<Link href="/product/[id]" params={{ id: '123' }}>
  View Product
</Link>

// router.push('/nonexistent') would be a compile error

Run npx expo-env to generate types for your route structure.

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

// Imperative navigation
router.push('/product/123');                      // Push onto stack
router.replace('/home');                          // Replace current screen
router.back();                                    // Go back one level
router.navigate('/tabs/profile');                 // Navigate (reuse if in history)
router.dismiss();                                 // Dismiss a modal
router.prefetch('/product/[id]');                 // Pre-warm a route

// Declarative navigation
<Link href="/product/123">View Product</Link>
<Link href={{ pathname: '/product/[id]', params: { id: '123' } }}>View</Link>

// Check if going back is possible
const router = useRouter();
return router.canGoBack() ? <Button onPress={() => router.back()} /> : null;

Modals and Overlays

Present screens as modals via the root _layout.tsx Stack:

// app/_layout.tsx — present specific screens as modals
<Stack>
  <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
  <Stack.Screen
    name="modal/share"
    options={{
      presentation: 'modal',            // Bottom sheet style
      headerTitle: 'Share',
    }}
  />
  <Stack.Screen
    name="modal/filter"
    options={{
      presentation: 'transparentModal', // Overlay with transparency
      animation: 'slide_from_bottom',
    }}
  />
</Stack>

Deep Linking

Expo Router automatically generates deep links from the file structure — no manual link configuration required:

// app.json
{
  "expo": {
    "scheme": "yourapp",
    "web": { "bundler": "metro" }
  }
}

With this config, app/product/[id].tsx is deep-linkable as:

  • yourapp://product/123 — native universal scheme
  • https://yourapp.com/product/123 — Universal Links / App Links
{
  "expo": {
    "ios": {
      "associatedDomains": ["applinks:yourapp.com"]
    }
  }
}
# iOS simulator
xcrun simctl openurl booted "yourapp://product/123"

# Android emulator
adb shell am start -a android.intent.action.VIEW -d "yourapp://product/123"

Authentication Flows

The recommended pattern for protected routes uses layout-level redirect logic in each route group:

// app/(auth)/_layout.tsx — redirect authenticated users away from auth screens
import { useEffect } from 'react';
import { Stack, router } from 'expo-router';
import { useAuth } from '@/hooks/useAuth';

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

  useEffect(() => {
    if (!isLoading && isAuthenticated) {
      router.replace('/(tabs)'); // Redirect to main app when authenticated
    }
  }, [isAuthenticated, isLoading]);

  if (isLoading) return <SplashScreen />;

  return <Stack screenOptions={{ headerShown: false }} />;
}
// app/(tabs)/_layout.tsx — redirect unauthenticated users to login
export default function TabsLayout() {
  const { isAuthenticated, isLoading } = useAuth();

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.replace('/(auth)/login');
    }
  }, [isAuthenticated, isLoading]);

  // Prevent flash of protected content during redirect
  if (!isAuthenticated) return null;

  return <Tabs>...</Tabs>;
}

The if (!isAuthenticated) return null guard before the <Tabs> render is critical — without it, the tab navigator briefly renders before the redirect executes, causing a visible flash of the protected interface.

Route Protection with Middleware

For web targets, Expo Router supports server-side middleware:

// app/+middleware.ts
import type { MiddlewareRequest } from 'expo-router/server';

export function middleware(request: MiddlewareRequest) {
  const { pathname } = request.nextUrl;

  // Protect dashboard routes
  const protectedPaths = ['/dashboard', '/settings', '/profile'];
  const isProtected = protectedPaths.some(p => pathname.startsWith(p));

  if (isProtected) {
    const token = request.cookies.get('session');
    if (!token) {
      return Response.redirect(new URL('/login', request.url));
    }
  }

  return undefined; // Continue to route
}

Nested Layouts

Complex applications need layouts within layouts — a tab contains a stack, which contains its own screens:

// app/(tabs)/(home)/_layout.tsx
import { Stack } from 'expo-router';

export default function HomeStackLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Feed' }} />
      <Stack.Screen name="post/[id]" options={{ title: 'Post' }} />
    </Stack>
  );
}

Navigating from the messages list to a conversation pushes onto the stack inside the tab — without leaving the tab bar. This is the nested layout pattern that most closely mirrors native app navigation behavior.

Performance Optimizations

Optimization Description Trade-off
Lazy loading Screens load on navigation (default) None
Lean layouts No expensive computation in _layout.tsx None
Pre-load routes router.prefetch() for likely next screens Memory
lazy: false on tabs Mount all tabs upfront Initial render time
Component splitting Load heavy components only when needed Complexity
// WRONG — _layout.tsx re-renders on every navigation event
export default function Layout() {
  const expensiveData = useExpensiveCalculation(); // Re-runs on every nav
  return <Stack />;
}

// RIGHT — move expensive computation into individual screens
export default function Layout() {
  return <Stack />; // Layout stays lean; screens handle their own data
}

Expo Router vs React Navigation

Expo Router is built on React Navigation — not a replacement, but a higher-level abstraction:

Dimension Expo Router React Navigation (Manual)
Route definition File system Imperative config
Deep linking Automatic Manual linking config
Type-safe routes Typed hrefs (v3+) Manual typing
Web URL sync Built-in Manual for web
Code organization Co-located with components Centralized navigator
Learning curve Lower (Next.js familiar) Higher
Customization Lower (opinionated) Higher

When to still choose React Navigation directly: highly custom navigation transitions, very complex nested navigator configurations, apps already built on React Navigation with significant investment.

API Reference

# Available hooks
hooks:
  - "useRouter" - Navigate programmatically
  - "useLocalSearchParams" - Get route params
  - "useGlobalSearchParams" - Global URL params
  - "useNavigation" - React Navigation API
  - "useSegments" - Current route segments

# Components
components:
  - "Link" - Declarative navigation (like <a>)
  - "Stack" - Stack navigator
  - "Tabs" - Tab navigator
  - "Slot" - Renders the active child route
  - "Redirect" - Declarative redirect

# Functions
functions:
  - "router.push" - Push route onto stack
  - "router.replace" - Replace current route
  - "router.back" - Navigate back
  - "router.navigate" - Navigate (reuse history)
  - "router.dismiss" - Dismiss modal
  - "router.prefetch" - Pre-warm a route

Common Pitfalls

Pitfall Problem Fix
Overly nested folders Complex, hard to maintain Flat structure with route groups
Heavy _layout.tsx Re-renders on every nav Move computation to screens
Forgetting initialRouteName Wrong back behavior after deep link Configure unstable_settings.initialRouteName
Ignoring param types String params bite TS users Parse explicitly: Array.isArray(id) ? id[0] : id
Mixing React Navigation patterns Confusion Follow Expo Router file-based conventions
No flash prevention Auth UI flash if (!isAuthenticated) return null

Frequently Asked Questions

Q: Expo Router or React Navigation in 2026? A: For new Expo projects, Expo Router is the recommended default — file-based routing, automatic deep linking, typed routes, and web support come for free. React Navigation is better for custom navigators or migrating a legacy app.

Q: Does Expo Router support web? A: Yes, first-class. The same file that generates iOS and Android apps also produces a web app with URL-mapped routes. Run npx expo start --web.

Q: How do dynamic routes work? A: Files like [id].tsx capture URL parameters, accessed via useLocalSearchParams(). Params are always strings — convert explicitly to numbers.

Q: Can I migrate an existing React Navigation app? A: Yes, incrementally. Add Expo Router, move new screens into the app/ directory, keep existing navigators for unmigrated parts. Not a weekend migration for large apps, but the path is real.

Q: What are the performance considerations? A: Expo Router lazy-loads screens by default. Keep layouts lean (they re-render on navigation), use router.prefetch() for likely next screens, and consider lazy: false for tabs with expensive initialization.

Project Structure Best Practices

app/                            # Routes (navigation = file structure)
├── _layout.tsx                 # Root: providers, Stack, auth redirect
├── (tabs)/                     # Tab group (no URL segment)
├── (auth)/                     # Auth group (login, signup)
├── product/
│   ├── _layout.tsx             # Product stack with headers
│   ├── index.tsx               # Product list
│   └── [id].tsx                # Product detail
├── modal/
│   └── share.tsx               # Modal presentation
└── +not-found.tsx              # 404 screen

Keep the structure flat. Group by feature or domain, use route groups for navigation organization, and use _layout.tsx for shared layouts. This keeps navigation scalable and easy for new developers to understand.

Key Takeaways

  • File-based routing — Routes from file structure
  • Layouts — Shared UI for groups (tabs, stacks, drawers)
  • Dynamic routes — Parameters in URLs via [param].tsx
  • Deep linking — Built-in, zero-config, works on native + web
  • Typed routes — Compile-time navigation safety
  • Auth flows — Layout-level redirects with flash prevention
  • Route groups — Organize files without affecting URLs

External Resources

Resources

Comments

👍 Was this article helpful?