Moving from Expo Router
Expo Router is a file-based router for React Native and Web apps. Moving to React Navigation means replacing file-based configuration with code-based configuration.
This guide intends to cover the main differences and the broad concepts between the two to get you started.
Why React Navigation
Comparison with Expo Router
Some of the benefits of React Navigation are:
- More expressive APIs for precise control over navigation structure and behavior.
- Modeled on state objects instead of URLs, which encode more information and enable features such as
resetand navigation state persistence. - More flexible deep linking, such as parsing and schema support for params, regex support for paths, and even fully custom parsing with
getStateFromPathetc. - URL segments are not coupled to navigator nesting structure, making it possible to change the UI without affecting public URLs.
Some of the benefits of Expo Router are:
- File-based configuration for routes, which can be faster to set up and is familiar when coming from web frameworks.
- Support for web-specific features such as static and server rendering, which can be beneficial for apps that need them for SEO.
- Expo CLI and bundler integration that automates tasks such as code-splitting and lazy loading of routes.
- Part of the Expo framework, making it more closely integrated with Expo SDK and tools.
Common misconceptions
React Navigation requires a lot of boilerplate
React Navigation's static configuration API is designed to minimize boilerplate. It provides automatic type inference based on the navigator and linking configuration, and automatic paths for deep linking out of the box.
React Navigation doesn't support Web
React Navigation supports Web out-of-the-box with the same API as native. The Web implementation includes support for browser URLs, history, and accessibility features. However, the web support is optimized for PWAs and doesn't have full support for server rendering.
React Navigation doesn't use native navigation primitives
Official navigators in React Navigation such as Native Stack and Bottom Tabs use native primitives by default on Android & iOS, including platform styling such as Liquid Glass on supported iOS versions.
Mental model
Expo Router starts with URLs and files:
- Files in
appbecome routes. _layout.tsxfiles define how child routes are nested.- The path is inferred from file names such as
index.tsx,[id].tsx, and route groups like(tabs). - APIs such as
Link,Redirect,router, anduseLocalSearchParamswork with those inferred paths.
React Navigation starts with the navigation tree:
- Screens are declared in a navigator's
screensobject. - Nested layouts are modeled by nesting navigators.
- URLs are configured with the
linkingoption. - Navigation happens with screen names and params.
Migration checklist
-
Remove Expo Router as the entry point.
If your app uses
expo-router/entryinpackage.json, replace it with your app's entry file. If you don't use Expo Router anywhere else, remove theexpo-routerpackage and theexpo-routerconfig plugin from your Expo app config.package.json{
"main": "expo-router/entry",
"main": "index.js",
}index.jsimport { registerRootComponent } from 'expo';
import App from './src/App';
registerRootComponent(App); -
Create a root navigator.
Define your root navigator with a
createXNavigatorfunction, create screen configs with the matchingcreateXScreenhelper, and render it withcreateStaticNavigation:import { createStaticNavigation } from '@react-navigation/native';
import {
createNativeStackNavigator,
createNativeStackScreen,
} from '@react-navigation/native-stack';
const RootStack = createNativeStackNavigator({
screens: {
Home: createNativeStackScreen({
screen: HomeScreen,
}),
Profile: createNativeStackScreen({
screen: ProfileScreen,
}),
},
});
const Navigation = createStaticNavigation(RootStack);
export default function App() {
return <Navigation />;
}
type RootStackType = typeof RootStack;
declare module '@react-navigation/core' {
interface RootNavigator extends RootStackType {}
} -
Move each route file into a screen registration.
A route file such as
app/profile/[userId].tsxbecomes a screen component registered in a navigator. Keep the component code, but replace Expo Router hooks with React Navigation hooks such asuseRouteanduseNavigation.const RootStack = createNativeStackNavigator({
screens: {
Profile: createNativeStackScreen({
screen: ProfileScreen,
}),
},
});function ProfileScreen() {
const route = useRoute('Profile');
const { userId } = route.params;
// ...
} -
Recreate
_layout.tsxfiles with nested navigators.A layout returning
<Stack />becomescreateNativeStackNavigatororcreateStackNavigator,<Tabs />becomescreateBottomTabNavigator, and<Drawer />becomescreateDrawerNavigator. Nested layout files become nested navigators.const HomeTabs = createBottomTabNavigator({
screens: {
Home: createBottomTabScreen({
screen: HomeScreen,
}),
Feed: createBottomTabScreen({
screen: FeedScreen,
}),
},
});
const RootStack = createNativeStackNavigator({
screens: {
Main: createNativeStackScreen({
screen: HomeTabs,
}),
},
}); -
Recreate URLs with linking configuration.
Expo Router infers paths from file names. React Navigation generates paths automatically based on screen names, and you can override paths with the
linkingproperty on a screen for custom patterns.Specify
linkingmanually when the URL includes params, e.g., when a screen has path params such as[userId], when you need a catch-all or 404 route, or when the public URL should differ from the screen name.const RootStack = createNativeStackNavigator({
screens: {
Profile: createNativeStackScreen({
screen: ProfileScreen,
linking: 'profile/:userId',
}),
},
});For example, Expo Router's
app/profile/[userId].tsxcan become a root stack screen withlinking: 'profile/:userId'. -
Replace redirects and protected routes.
Replace
Redirectand protected route groups with conditional screens or groups. In static configuration, use theifproperty described in the authentication flow guide. When the condition changes, screens that no longer match are removed from the navigation state.const RootStack = createNativeStackNavigator({
screens: {
Home: createNativeStackScreen({
if: useIsSignedIn,
screen: HomeScreen,
}),
SignIn: createNativeStackScreen({
if: useIsSignedOut,
screen: SignInScreen,
}),
},
}); -
Handle unmatched routes and deep links.
Replace
+not-foundwith a screen that uses a catch-all linking path such as'*'. Replace+native-intentwithgetInitialURLandsubscribein thelinkingconfiguration.const RootStack = createNativeStackNavigator({
screens: {
NotFound: createNativeStackScreen({
screen: NotFoundScreen,
linking: '*',
}),
},
});
API mapping
Files and layouts
| Expo Router | React Navigation |
|---|---|
app/index.tsx | A screen with linking: ''. Auto-detected based on the order or initialRouteName |
app/profile/[userId].tsx | A screen with linking such as profile/:userId |
app/[...slug].tsx | A screen with linking: '*'. |
app/+not-found.tsx | A screen with linking: '*' |
Groups such as (tabs) | Groups in navigator configuration. |
_layout.tsx | Navigator configuration. |
unstable_settings.initialRouteName | initialRouteName on the navigator. |
Components and hooks
| Expo Router | React Navigation |
|---|---|
Stack | createNativeStackNavigator |
Tabs | createBottomTabNavigator. |
Drawer | createDrawerNavigator |
SplitView | No direct equivalent, but planned. |
Link | Link with a screen name and params, or a reusable custom link built with useLinkProps |
Redirect | Conditional screens/groups with if, or navigation.replace |
router.push | navigation.push in stack navigators or navigation.pushParams |
router.navigate | navigation.navigate |
router.replace | navigation.replace in stack navigators |
router.back | navigation.goBack |
router.setParams | navigation.setParams, navigation.replaceParams and navigation.pushParams |
router.prefetch | navigation.preload |
useRouter | useNavigation |
useLocalSearchParams | useRoute and params from the route object |
useGlobalSearchParams | No direct equivalent. |
usePathname | useRoutePath |
useSegments | No direct equivalent. |
useFocusEffect | useFocusEffect |
useNavigation | useNavigation |
useSitemap | No direct equivalent. |
useLoaderData | No direct equivalent, but planned. |
Features without direct replacements
| Expo Router | Alternative |
|---|---|
| Typed routes | Type inference from the navigator and linking configuration. |
| Protected routes | Conditional screens or groups with if. |
| Native tabs | Bottom tabs which render native tabs by default on Android & iOS. |
Headless tabs from expo-router/ui | Custom tabBar or custom navigator. |
| API routes and server middleware | No direct equivalent. Move this logic to your server. |
| Server rendering | Server rendering with manual setup. |
| Static rendering | No direct equivalent. |
| Link preview and link menu | Custom UI or platform-specific components. |
| Zoom transition | No direct equivalent, but planned. |
Stack.Header, Stack.Toolbar, Stack.SearchBar etc. | Screen options, native stack options, custom headers etc. |
Color | PlatformColor from React Native, Material Themes on Android. |
Example setup
Expo Router
Consider this Expo Router structure:
app
|-- _layout.tsx
|-- +not-found.tsx
|-- (tabs)
| |-- _layout.tsx
| |-- index.tsx
| `-- feed
| |-- _layout.tsx
| |-- index.tsx
| `-- [postId].tsx
`-- profile
`-- [userId].tsx
The corresponding Expo Router layouts may configure multiple navigators:
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack
screenOptions={{
headerBackButtonDisplayMode: 'minimal',
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="profile/[userId]"
options={{
presentation: 'modal',
}}
/>
</Stack>
);
}
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabsLayout() {
return (
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="feed">
<NativeTabs.Trigger.Label>Feed</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="list.bullet" md="list" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
import { Stack } from 'expo-router';
export default function FeedLayout() {
return (
<Stack
screenOptions={{
headerLargeTitle: true,
}}
>
<Stack.Screen
name="index"
options={{
headerSearchBarOptions: {
placeholder: 'Search posts',
},
}}
/>
<Stack.Screen
name="[postId]"
options={{
headerLargeTitle: false,
}}
/>
</Stack>
);
}
React Navigation
Model the same screens and options with explicit nested navigators:
import {
createNativeStackNavigator,
createNativeStackScreen,
} from '@react-navigation/native-stack';
import { HomeTabs } from './HomeTabs';
import { NotFoundScreen } from './NotFoundScreen';
import { ProfileScreen } from './ProfileScreen';
export const RootStack = createNativeStackNavigator({
screenOptions: {
headerBackButtonDisplayMode: 'minimal',
},
screens: {
Main: createNativeStackScreen({
screen: HomeTabs,
options: {
headerShown: false,
},
}),
Profile: createNativeStackScreen({
screen: ProfileScreen,
linking: 'profile/:userId',
options: {
presentation: 'modal',
},
}),
NotFound: createNativeStackScreen({
screen: NotFoundScreen,
linking: '*',
}),
},
});
import {
createBottomTabNavigator,
createBottomTabScreen,
} from '@react-navigation/bottom-tabs';
import { Platform } from 'react-native';
import { FeedStack } from './FeedStack';
import { HomeScreen } from './HomeScreen';
export const HomeTabs = createBottomTabNavigator({
screenOptions: {
headerShown: false,
tabBarMinimizeBehavior: 'onScrollDown',
},
screens: {
Home: createBottomTabScreen({
screen: HomeScreen,
options: {
tabBarLabel: 'Home',
tabBarIcon: Platform.select({
ios: {
type: 'sfSymbol',
name: 'house',
},
android: {
type: 'materialSymbol',
name: 'home',
},
}),
},
}),
Feed: createBottomTabScreen({
screen: FeedStack,
linking: 'feed',
options: {
tabBarLabel: 'Feed',
tabBarIcon: Platform.select({
ios: {
type: 'sfSymbol',
name: 'list.bullet',
},
android: {
type: 'materialSymbol',
name: 'list',
},
}),
},
}),
},
});
import {
createNativeStackNavigator,
createNativeStackScreen,
} from '@react-navigation/native-stack';
import { FeedListScreen } from './FeedListScreen';
import { PostScreen } from './PostScreen';
export const FeedStack = createNativeStackNavigator({
screenOptions: {
headerLargeTitleEnabled: true,
},
screens: {
FeedList: createNativeStackScreen({
screen: FeedListScreen,
linking: '',
options: {
headerSearchBarOptions: {
placeholder: 'Search posts',
},
},
}),
Post: createNativeStackScreen({
screen: PostScreen,
linking: ':postId',
options: {
headerLargeTitleEnabled: false,
},
}),
},
});
import { createStaticNavigation } from '@react-navigation/native';
import { RootStack } from './RootStack';
const Navigation = createStaticNavigation(RootStack);
export default function App() {
return <Navigation />;
}
type RootStackType = typeof RootStack;
declare module '@react-navigation/core' {
interface RootNavigator extends RootStackType {}
}
After this migration:
app/(tabs)/index.tsxmaps toHome.app/(tabs)/feed/index.tsxmaps toFeedList.app/(tabs)/feed/[postId].tsxmaps toPostwithroute.params.postId.app/profile/[userId].tsxmaps toProfilewithroute.params.userId.app/+not-found.tsxmaps toNotFound.
Replacing navigation and links
Expo Router links and navigation calls use URL paths:
import { Link } from 'expo-router';
import { Pressable, Text } from 'react-native';
<Link
href={{
pathname: '/profile/[userId]',
params: { userId: 'jane' },
}}
asChild
>
<Pressable>
<Text>Open profile</Text>
</Pressable>
</Link>;
router.push('/profile/jane');
router.push({
pathname: '/profile/[userId]',
params: { userId: 'jane' },
});
router.replace('/profile/jane');
router.replace({
pathname: '/profile/[userId]',
params: { userId: 'jane' },
});
router.setParams({ tab: 'posts' });
React Navigation links and navigation calls use screen names and params. For custom link UI, create a reusable component with useLinkProps and use that component throughout your app:
import type { ReactNode } from 'react';
import type { LinkProps } from '@react-navigation/native';
import { useLinkProps } from '@react-navigation/native';
import { Pressable, Text } from 'react-native';
type MyLinkProps = LinkProps & {
children: ReactNode;
};
function MyLink({ children, ...options }: MyLinkProps) {
const props = useLinkProps(options);
return (
<Pressable {...props}>
<Text>{children}</Text>
</Pressable>
);
}
<MyLink screen="Profile" params={{ userId: 'jane' }}>
Open profile
</MyLink>;
navigation.push('Profile', { userId: 'jane' });
navigation.replace('Profile', { userId: 'jane' });
navigation.setParams({ tab: 'posts' });
For most app code, prefer screen names and params. They are easier to type-check and don't require manually building URLs.