Expo Router: File-Based Navigation for React Native Apps
React Native navigation has always been complicated. React Navigation requires manual route registration, nested navigator configuration, and TypeScript types that don’t stay in sync with your actual screens. Expo Router solves this by bringing file-based routing to React Native — the same pattern that made Next.js navigation effortless. Therefore, this guide covers everything from basic setup to advanced patterns like typed routes, deep linking, and authentication flows.
Why File-Based Routing Changes Everything
In React Navigation, adding a new screen requires three steps: create the component, register it in a navigator, and add TypeScript types for the route params. With Expo Router, you create a file in the app/ directory and it automatically becomes a route. The file path is the URL path. No registration, no manual type definitions, no navigator configuration files.
Moreover, Expo Router provides universal deep linking out of the box. Every screen in your app has a URL that works on iOS, Android, and web. Users can share links to specific screens, push notifications can navigate to any route, and your app handles incoming URLs without any additional configuration. This was possible with React Navigation but required significant setup for each route.
app/
├── _layout.tsx → Root layout (tab/stack navigator)
├── index.tsx → / (home screen)
├── settings.tsx → /settings
├── profile/
│ ├── _layout.tsx → Nested layout for profile section
│ ├── index.tsx → /profile
│ └── [id].tsx → /profile/123 (dynamic route)
├── (tabs)/
│ ├── _layout.tsx → Tab navigator layout
│ ├── home.tsx → Tab: Home
│ ├── search.tsx → Tab: Search
│ └── account.tsx → Tab: Account
├── (auth)/
│ ├── _layout.tsx → Auth group layout
│ ├── login.tsx → /login
│ └── register.tsx → /register
└── [...missing].tsx → 404 catch-all routeThe file structure above creates a complete navigation system: tab navigation, stack navigation, dynamic routes, authentication flows, and a 404 handler — all from the file system. No createStackNavigator, no NavigationContainer, no route registration.
Layouts: Stack, Tabs, and Drawers
Expo Router uses _layout.tsx files to define how child routes are rendered. Each layout wraps its child screens with a navigator — Stack, Tabs, or Drawer. This replaces React Navigation’s nested navigator pattern with a cleaner, co-located approach.
// app/_layout.tsx — Root layout with Stack navigator
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen
name="modal"
options={{ presentation: 'modal' }}
/>
</Stack>
);
}
// app/(tabs)/_layout.tsx — Tab navigator
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs screenOptions={{
tabBarActiveTintColor: '#007AFF',
headerShown: true
}}>
<Tabs.Screen
name="home"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) =>
<Ionicons name="home" size={size} color={color} />
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color, size }) =>
<Ionicons name="search" size={size} color={color} />
}}
/>
<Tabs.Screen
name="account"
options={{
title: 'Account',
tabBarIcon: ({ color, size }) =>
<Ionicons name="person" size={size} color={color} />
}}
/>
</Tabs>
);
}Route groups (parentheses folders like (tabs) and (auth)) organize routes without affecting the URL. The (tabs) group creates tab navigation but the URLs are /home, /search, /account — not /tabs/home. This separation of navigation structure from URL structure is one of Expo Router’s most powerful features.
Typed Routes and Navigation
Expo Router generates TypeScript types automatically from your file structure. When you navigate to a route, TypeScript validates both the route path and the required parameters. Consequently, typos in route names and missing parameters become compile-time errors instead of runtime crashes.
// app/profile/[id].tsx — Dynamic route with typed params
import { useLocalSearchParams, Link, router } from 'expo-router';
export default function ProfileScreen() {
// TypeScript knows 'id' exists because the file is [id].tsx
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View>
<Text>Profile: {id}</Text>
{/* Type-safe Link — IDE autocompletes route paths */}
<Link href="/settings">Settings</Link>
{/* Dynamic route with params */}
<Link href={`/profile/${userId}`}>
View Profile
</Link>
{/* Programmatic navigation */}
<Pressable onPress={() => {
router.push({
pathname: '/profile/[id]',
params: { id: '456' }
});
}}>
<Text>Navigate</Text>
</Pressable>
{/* Replace current screen (no back button) */}
<Pressable onPress={() => router.replace('/login')}>
<Text>Logout</Text>
</Pressable>
</View>
);
}
// Enable typed routes in app.json
// { "expo": { "experiments": { "typedRoutes": true } } }Additionally, useLocalSearchParams returns values that persist across re-renders (unlike useSearchParams which re-renders on every navigation). This distinction matters for performance — use useLocalSearchParams for screen-level params and useGlobalSearchParams only when you need to react to changes from other screens.
Authentication Flows and Route Protection
Protecting routes in Expo Router uses a redirect pattern in the root layout. Instead of conditional navigator rendering (the React Navigation approach), you check authentication state and redirect unauthenticated users to the login screen.
// app/_layout.tsx — Auth-protected root layout
import { Slot, useRouter, useSegments } from 'expo-router';
import { useAuth } from '../contexts/AuthContext';
import { useEffect } from 'react';
export default function RootLayout() {
const { user, isLoading } = useAuth();
const router = useRouter();
const segments = useSegments();
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
// Not logged in, redirect to login
router.replace('/login');
} else if (user && inAuthGroup) {
// Logged in but on auth screen, redirect to home
router.replace('/home');
}
}, [user, isLoading, segments]);
if (isLoading) return <LoadingScreen />;
return <Slot />;
}This pattern is simpler and more predictable than conditionally rendering different navigators. The navigation structure is always the same — the layout just redirects based on auth state. Furthermore, deep links work correctly with this pattern: if a logged-out user opens a deep link to /profile/123, they get redirected to login and then back to the profile after authentication.
Migrating from React Navigation
Migration from React Navigation to Expo Router can be done incrementally. Start by converting your app entry point and root navigator, then migrate screens one at a time. The key mapping: createStackNavigator becomes a _layout.tsx with <Stack>, createBottomTabNavigator becomes a (tabs)/_layout.tsx with <Tabs>, and navigation.navigate('Screen') becomes router.push('/screen').
Most React Navigation screen options translate directly — headerTitle, headerStyle, tabBarIcon, and presentation all work the same way. However, custom navigators (like drawer with custom content) require more adaptation. The Expo Router team provides a migration guide with detailed mapping for every React Navigation API.
Related Reading:
Resources:
In conclusion, Expo Router brings the simplicity of file-based routing to React Native. It eliminates boilerplate navigator configuration, provides automatic deep linking, generates TypeScript types from your file structure, and simplifies authentication flows. If you’re starting a new React Native project or modernizing an existing one, Expo Router is the recommended navigation solution.