Skip to main content
Version: 8.x

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 reset and 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 getStateFromPath etc.
  • 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 app become routes.
  • _layout.tsx files 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, and useLocalSearchParams work with those inferred paths.

React Navigation starts with the navigation tree:

  • Screens are declared in a navigator's screens object.
  • Nested layouts are modeled by nesting navigators.
  • URLs are configured with the linking option.
  • Navigation happens with screen names and params.

Migration checklist

  1. Remove Expo Router as the entry point.

    If your app uses expo-router/entry in package.json, replace it with your app's entry file. If you don't use Expo Router anywhere else, remove the expo-router package and the expo-router config plugin from your Expo app config.

    package.json
    {
    "main": "expo-router/entry",
    "main": "index.js",
    }
    index.js
    import { registerRootComponent } from 'expo';

    import App from './src/App';

    registerRootComponent(App);
  2. Create a root navigator.

    Define your root navigator with a createXNavigator function, create screen configs with the matching createXScreen helper, and render it with createStaticNavigation:

    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 {}
    }
  3. Move each route file into a screen registration.

    A route file such as app/profile/[userId].tsx becomes a screen component registered in a navigator. Keep the component code, but replace Expo Router hooks with React Navigation hooks such as useRoute and useNavigation.

    const RootStack = createNativeStackNavigator({
    screens: {
    Profile: createNativeStackScreen({
    screen: ProfileScreen,
    }),
    },
    });
    function ProfileScreen() {
    const route = useRoute('Profile');

    const { userId } = route.params;

    // ...
    }
  4. Recreate _layout.tsx files with nested navigators.

    A layout returning <Stack /> becomes createNativeStackNavigator or createStackNavigator, <Tabs /> becomes createBottomTabNavigator, and <Drawer /> becomes createDrawerNavigator. 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,
    }),
    },
    });
  5. 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 linking property on a screen for custom patterns.

    Specify linking manually 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].tsx can become a root stack screen with linking: 'profile/:userId'.

  6. Replace redirects and protected routes.

    Replace Redirect and protected route groups with conditional screens or groups. In static configuration, use the if property 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,
    }),
    },
    });
  7. Handle unmatched routes and deep links.

    Replace +not-found with a screen that uses a catch-all linking path such as '*'. Replace +native-intent with getInitialURL and subscribe in the linking configuration.

    const RootStack = createNativeStackNavigator({
    screens: {
    NotFound: createNativeStackScreen({
    screen: NotFoundScreen,
    linking: '*',
    }),
    },
    });

API mapping

Files and layouts

Expo RouterReact Navigation
app/index.tsxA screen with linking: ''. Auto-detected based on the order or initialRouteName
app/profile/[userId].tsxA screen with linking such as profile/:userId
app/[...slug].tsxA screen with linking: '*'.
app/+not-found.tsxA screen with linking: '*'
Groups such as (tabs)Groups in navigator configuration.
_layout.tsxNavigator configuration.
unstable_settings.initialRouteNameinitialRouteName on the navigator.

Components and hooks

Expo RouterReact Navigation
StackcreateNativeStackNavigator
TabscreateBottomTabNavigator.
DrawercreateDrawerNavigator
SplitViewNo direct equivalent, but planned.
LinkLink with a screen name and params, or a reusable custom link built with useLinkProps
RedirectConditional screens/groups with if, or navigation.replace
router.pushnavigation.push in stack navigators or navigation.pushParams
router.navigatenavigation.navigate
router.replacenavigation.replace in stack navigators
router.backnavigation.goBack
router.setParamsnavigation.setParams, navigation.replaceParams and navigation.pushParams
router.prefetchnavigation.preload
useRouteruseNavigation
useLocalSearchParamsuseRoute and params from the route object
useGlobalSearchParamsNo direct equivalent.
usePathnameuseRoutePath
useSegmentsNo direct equivalent.
useFocusEffectuseFocusEffect
useNavigationuseNavigation
useSitemapNo direct equivalent.
useLoaderDataNo direct equivalent, but planned.

Features without direct replacements

Expo RouterAlternative
Typed routesType inference from the navigator and linking configuration.
Protected routesConditional screens or groups with if.
Native tabsBottom tabs which render native tabs by default on Android & iOS.
Headless tabs from expo-router/uiCustom tabBar or custom navigator.
API routes and server middlewareNo direct equivalent. Move this logic to your server.
Server renderingServer rendering with manual setup.
Static renderingNo direct equivalent.
Link preview and link menuCustom UI or platform-specific components.
Zoom transitionNo direct equivalent, but planned.
Stack.Header, Stack.Toolbar, Stack.SearchBar etc.Screen options, native stack options, custom headers etc.
ColorPlatformColor 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:

app/_layout.tsx
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>
);
}
app/(tabs)/_layout.tsx
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>
);
}
app/(tabs)/feed/_layout.tsx
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:

src/RootStack.tsx
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: '*',
}),
},
});
src/HomeTabs.tsx
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',
},
}),
},
}),
},
});
src/FeedStack.tsx
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,
},
}),
},
});
src/App.tsx
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.tsx maps to Home.
  • app/(tabs)/feed/index.tsx maps to FeedList.
  • app/(tabs)/feed/[postId].tsx maps to Post with route.params.postId.
  • app/profile/[userId].tsx maps to Profile with route.params.userId.
  • app/+not-found.tsx maps to NotFound.

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.