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.
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 directoryapp/about.tsx— A screen at/aboutapp/(tabs)/index.tsx— A tab navigator group (parentheses create groups)app/[id].tsx— A dynamic route with a parameter namedidapp/[...slug].tsx— A catch-all route for nested pathsapp/_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.
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-appOr add Expo Router to an existing project:
npx expo install expo-router expo-linking expo-constants expo-status-barUpdate 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} />;
}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
-
Use typed routes for type safety: Enable typed routes in your
app.jsonwith"experiments": { "typedRoutes": true }to get autocompletion and compile-time validation of all navigation calls. -
Organize with route groups: Use parenthesized directory names like
(auth),(tabs), and(modals)to organize related screens without affecting URL paths. -
Implement loading and error states at the layout level: Use the
_layout.tsxfiles to define loading indicators and error boundaries that apply to all child routes. -
Optimize with route preloading: Use
router.prefetch('/products/[id]')to preload screens that users are likely to navigate to, reducing perceived load times. -
Configure deep linking for all platforms: Set up iOS Universal Links and Android App Links by configuring your
app.jsonwith proper associated domains and intent filters. -
Use
useNavigationfor dynamic header configuration: Update screen options dynamically usingnavigation.setOptions()within screen components rather than hardcoding everything in layouts. -
Keep layout files focused: Layout files should only contain navigator configuration and shared UI elements. Screen-specific logic belongs in the screen files.
-
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
| Pitfall | Impact | Solution |
|---|---|---|
Forgetting _layout.tsx in a directory | Routes render without navigator wrapper | Always create a layout file when adding a new directory |
| Circular imports between layout and screen files | App crashes on startup with confusing errors | Extract shared components into a separate components/ directory |
| Dynamic route params lost on deep link | Params are undefined when app opens from a deep link | Use useGlobalSearchParams() instead of useLocalSearchParams() for params that span layouts |
| Tab state lost when navigating between tabs | Each tab remounts instead of preserving state | Ensure tab layout uses Tabs navigator, not nested Stack navigators |
| Modal dismisses instead of going back | router.back() closes the modal instead of navigating within it | Use router.canGoBack() and check navigation state before calling back() |
| Type errors with dynamic route params | TypeScript errors when accessing params.id | Use 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
| Feature | Expo Router | React Navigation | React Native Navigation (Wix) |
|---|---|---|---|
| Routing Model | File-system based | Programmatic/Config-based | Programmatic/Config-based |
| Deep Linking | Automatic from file structure | Manual configuration | Manual configuration |
| Web Support | First-class (SSR compatible) | Limited | Not supported |
| Type Safety | Built-in typed routes | Manual typing with params | Manual typing |
| Native Feel | Standard native navigation | Standard native navigation | Truly native navigation controllers |
| Bundle Splitting | Automatic | Manual (lazy loading) | Manual |
| Learning Curve | Low (familiar from web) | Medium | High |
| Layout System | File-based layouts | Navigator composition | Navigation 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-campaignTesting 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:
- File structure IS your routing configuration — create a file, get a route
- Built on React Navigation — battle-tested reliability with modern DX
- Automatic deep linking — works across iOS Universal Links, Android App Links, and web
- Layout system prevents boilerplate — shared headers, auth guards, and tab bars in one file
- Typed routes catch errors at compile time — no more runtime navigation bugs
- Web-first architecture — SSR-compatible with automatic code splitting
- 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.