Web Development · Mobile
Expo Router in 2026: File-Based Navigation That Makes React Native Feel Modern
Expo Router brings file-based routing to React Native, the same pattern web developers know from Next.js. Here's how it works, what it gets right, and where the friction still lives.
Anurag Verma
8 min read
Sponsored
React Native navigation has always been the part of mobile development that web developers struggle with most. React Navigation works, but it’s explicit: you define a navigator, list the screens, configure transitions, wire up deep linking, and handle the stack state manually. After building a few apps, it becomes manageable. For the first few projects, it’s genuinely confusing.
Expo Router treats navigation like a file system, which is the pattern web developers already know. A file at app/profile/[id].tsx is the screen at /profile/:id. A file at app/(tabs)/home.tsx is a tab screen. Deep links, URL structure, and navigation state follow from the file structure rather than configuration.
This post covers how Expo Router actually works, what the file structure means for navigation, and where the approach still has rough edges.
The Setup
Expo Router is part of the Expo ecosystem and works with the managed workflow (Expo Go, EAS Build) or bare React Native projects:
npx create-expo-app@latest my-app --template tabs
cd my-app
npm install
npx expo start
The tabs template starts you with a working tab navigation setup. The project structure looks like:
app/
(tabs)/
_layout.tsx — defines the tab bar
index.tsx — first tab (Home)
explore.tsx — second tab
_layout.tsx — root layout, wraps everything
+not-found.tsx — 404 screen
assets/
components/
constants/
Every file inside app/ that exports a default React component becomes a navigable screen. The file path is the route.
File Conventions
Expo Router uses a few special filename patterns:
_layout.tsx — defines a layout that wraps all screens in the same directory. Use this for tab bars, drawer navigators, stack headers, and shared providers.
(group)/ — parenthesized folder names create route groups. The group name doesn’t appear in the URL. (tabs)/home.tsx maps to /home, not /(tabs)/home.
[param].tsx — dynamic segments. app/users/[id].tsx matches /users/123, /users/abc, etc.
[...rest].tsx — catch-all segments. app/docs/[...path].tsx matches /docs/getting-started, /docs/api/components, etc.
+not-found.tsx — shown when no other route matches.
+html.tsx — for web-specific HTML customization (Expo Router supports web targets).
Building a Tabbed App
Here’s a complete tab layout with two tabs:
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" color={color} size={size} />
),
}}
/>
</Tabs>
);
}
// app/(tabs)/index.tsx
import { View, Text, Pressable } from 'react-native';
import { Link } from 'expo-router';
export default function HomeScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Home</Text>
{/* Link component works like <a> on the web */}
<Link href="/users/123" asChild>
<Pressable>
<Text>Go to User 123</Text>
</Pressable>
</Link>
</View>
);
}
The Link component handles navigation without needing access to a navigation object. Same API as Next.js’s Link.
Dynamic Routes and Params
// app/users/[id].tsx
import { View, Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
export default function UserScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View>
<Text>User ID: {id}</Text>
</View>
);
}
Navigate programmatically using the router object:
import { router } from 'expo-router';
// Navigate to a route
router.push('/users/123');
// Replace (no back stack entry)
router.replace('/login');
// Go back
router.back();
// Navigate with typed params
router.push({ pathname: '/users/[id]', params: { id: '123' } });
The typed version catches typos at build time if you use TypeScript with Expo Router’s generated types. Run npx expo-env to generate types for your route structure.
Nested Layouts: Stack Inside a Tab
A common pattern is a tab bar with multiple screens per tab (a stack inside each tab). Here’s how that nests:
app/
(tabs)/
_layout.tsx — tab navigator
(home)/
_layout.tsx — stack navigator inside "home" tab
index.tsx — home feed
post/[id].tsx — individual post detail
profile.tsx — profile tab (no nested stack)
// 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>
);
}
Now tapping “Back” from the post detail screen returns to the feed, and the tab bar stays visible throughout. The nesting handles the state automatically.
Authentication Gates
A common need: redirect unauthenticated users to a login screen. Expo Router handles this with a root layout and conditional rendering:
// app/_layout.tsx
import { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { useAuth } from '../hooks/useAuth';
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 signed in, redirect to login
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
// Signed in, redirect to app
router.replace('/(tabs)');
}
}, [user, isLoading, segments]);
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
);
}
app/
_layout.tsx — root: handles auth redirect
(tabs)/ — protected screens
_layout.tsx
index.tsx
(auth)/ — public screens
_layout.tsx
login.tsx
signup.tsx
The useSegments() hook gives you the current route path as an array of segments, which you use to determine which group the user is in.
Deep Linking Without Configuration
One of Expo Router’s genuine advantages over manual React Navigation setup is deep linking. In React Navigation, you write a linking config object that maps URL patterns to screen names. It’s tedious for anything more than a few screens.
With Expo Router, deep links work automatically because the URL structure is the file structure. Configure the scheme in app.json:
{
"expo": {
"scheme": "myapp"
}
}
Then myapp://users/123 opens the app/users/[id].tsx screen with id="123". Universal links (HTTPS URLs that open the app) require an associated domain setup, but the routing configuration is already done.
For web targets (npx expo start --web), the same routes work as real URLs. The same app file renders in the browser with proper URL structure.
What’s Still Rough
Expo Router is genuinely useful, but there are places where the abstraction leaks.
Complex navigation flows are harder to trace. When navigation logic is spread across _layout.tsx files and useEffect hooks, debugging “why is the app navigating to X” requires tracing through multiple files. React Navigation’s explicit configuration is verbose but centralized.
Typed routes have caveats. The generated expo-env types help, but route parameters passed via useLocalSearchParams are always strings. A param like id=123 gives you "123", not 123. This bites TypeScript users who forget to parse.
Modals and sheets. Presenting a modal (a screen that slides up, has its own header, can be dismissed) works via presentation: 'modal' in screen options, but the interactions with the routing state can feel awkward when you want modal-like navigation that doesn’t add to the URL.
Performance on deep stacks. Expo Router renders all screens in a navigator at startup (for performance), which means complex apps need to be thoughtful about heavy initialization in screen components.
Comparing to React Navigation
Expo Router is built on React Navigation. They’re not alternatives in the sense that one replaces the other; Expo Router is an opinionated layer on top that handles routing configuration automatically.
| React Navigation | Expo Router | |
|---|---|---|
| Config style | Explicit navigator + screen definitions | File system |
| Deep linking | Manual linking config | Automatic from file structure |
| Web support | Limited (community packages) | First-class |
| Universal links | Separate config | Same routing, scheme config in app.json |
| Escape hatches | Full API access | Full React Navigation API underneath |
| Best for | Complex app-specific navigation flows | Standard app structures, universal apps |
If your app has unusual navigation requirements (non-linear flows, branching onboarding, game-like navigation), React Navigation’s explicit API gives you more control. If you’re building a standard app (tabs, stacks, modals) or want native + web from the same codebase, Expo Router’s defaults get you there faster.
Getting There from an Existing React Navigation App
Expo Router and React Navigation coexist. You can migrate one section of your app at a time by adopting Expo Router for new screens while keeping existing React Navigation code intact. The Expo Router documentation has a migration guide, but the short version:
- Add Expo Router to your project
- Move new screens into the
app/directory with Expo Router structure - Keep existing React Navigation navigators for parts you haven’t migrated
- Gradually move existing screens into
app/as you touch them
It’s not a weekend migration for a large app, but the incremental path is real.
For new projects in 2026, Expo Router is the default unless you have specific reasons to go with raw React Navigation. The conventions it provides are the same ones web developers already know, and the deep linking and web support you’d otherwise configure manually comes for free.
Sponsored
More from this category
More from Web Development
Fastify in 2026: The Node.js API Framework That Stayed When Everyone Left
Phoenix LiveView: Real-Time Web Features Without Writing a Line of JavaScript
Python asyncio in Production: The Pitfalls No One Warns You About
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored