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>
);
}
Navigation
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.
Navigation APIs
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 schemehttps://yourapp.com/product/123— Universal Links / App Links
Universal Links Setup (iOS)
{
"expo": {
"ios": {
"associatedDomains": ["applinks:yourapp.com"]
}
}
}
Testing Deep Links Locally
# 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
- Expo Router Docs
- Expo Router Example
- Expo Typed Routes
- Expo Deep Linking Guide
- React Navigation Migration Guide
Resources
- React Native Documentation
- Expo Documentation
- Expo Router GitHub
- React Navigation Documentation
- iOS Human Interface Guidelines
- Android Developer Guide
Comments