This documentation is available as Markdown for AI agents and LLMs. See the full Markdown index or append .md to any documentation URL.

Migrate Expo Router from SDK 57 to SDK 58

Edit page

Learn how to migrate your Expo Router application from SDK 57 to SDK 58.


SDK 58 changes how Expo Router builds navigation state and integrates with navigators. Apps that use Stack, Link, and useRouter may need a few changes. Apps that import from expo-router/react-navigation, access navigation state, or implement custom routers and navigators require closer review.

Use the following checklist to find the sections that apply to your app:

Common migrations

Prefer useRouter in components

Prefer the router returned by useRouter() over useNavigation().navigate() or the module-level router for href-based navigation from a component. The hook is bound to the Expo Router root that rendered the component.

The module-level router is still available, but it throws before the first router render and cannot distinguish between multiple router roots. Continue to use useNavigation when you need navigator-specific APIs, such as events, options, or action dispatching.

useRouter() includes the common navigation methods, such as push, navigate, replace, back, dismiss, dismissTo, dismissAll, canGoBack, canDismiss, setParams, and prefetch. If a required navigation method is missing, open an issue.

In SDK 57, React Navigation could interpret screen, params, and initial as instructions for building nested state. In SDK 58, they are ordinary user parameters. Navigate to a complete href instead:

app/profile.tsx
1import { useNavigation } from 'expo-router';
1import { useRouter } from 'expo-router';
22
3const navigation = useNavigation();
3const router = useRouter();
44
5navigation.navigate('(tabs)', {
6 screen: 'feed',
5router.push({
6 pathname: '/(tabs)/feed/[id]',
77params: { id: '42' },
88});

See Navigate to a screen in a nested navigator for more examples.

Ancestor route params are also no longer copied into descendant routes during imperative navigation. If your app relies on a descendant receiving a parameter from its parent route, include the value in the destination href. This makes imperative navigation consistent with opening the same href from a deep link or cold start.

Move initialRouteName to unstable_settings

The initialRouteName navigator prop is removed. To add a back destination when deep linking into a stack, export unstable_settings.anchor from that stack's layout:

app/(tabs)/_layout.tsx
11import { Stack } from 'expo-router';
22
3export const unstable_settings = {
4 anchor: 'index',
5};
6
37export default function FeedLayout() {
4 return <Stack initialRouteName="index" />;
8 return <Stack />;
59}

Expo Router now builds complete initial state from the URL. For example, a stack nested in tabs reads the setting from that stack's own _layout.tsx file. During imperative navigation, pass { withAnchor: true } when the anchor route should be inserted below the destination.

See Router settings for initial route and anchor behavior.

Replace redirect and initialParams

Layout Screen components no longer accept redirect or initialParams.

Render a Redirect from the route file instead of configuring redirect on a screen:

app/legacy.tsx
import { Redirect } from 'expo-router'; export default function LegacyRoute() { return <Redirect href="/replacement" />; }

For access control, use a protected route with redirectTo:

app/_layout.tsx
import { Stack } from 'expo-router'; import { useAuth } from '../context/auth'; export default function RootLayout() { const isSignedIn = useAuth(); return ( <Stack> <Stack.Protected guard={isSignedIn} redirectTo="/sign-in"> <Stack.Screen name="account" /> </Stack.Protected> <Stack.Screen name="sign-in" /> </Stack> ); }

Replace initialParams with defaults where the screen reads its params:

app/feed.tsx
import { useLocalSearchParams } from 'expo-router'; export default function Feed() { const { sort = 'latest' } = useLocalSearchParams<{ sort?: string }>(); // ... }

Declare tabs and drawer screens

JavaScript tabs, top tabs, drawer, headless tabs, and native tabs now show only screens declared in the layout. Declare every route that should appear in the navigator UI:

app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'; export default function TabLayout() { return ( <Tabs> <Tabs.Screen name="index" /> <Tabs.Screen name="feed" /> <Tabs.Screen name="hidden" options={{ href: null }} /> </Tabs> ); }

Update protected routes

Most apps that use protected routes do not need to make changes. Protected routes remain registered in their navigator in SDK 58. A route whose guard fails renders a redirect instead of being removed from the route tree.

Set redirectTo on the navigator's Protected component only when you want to control the redirect destination. Without it, Expo Router uses the navigator's accessible anchor or initial route, then its first accessible route.

See Protected routes for guard patterns.

Replace web modals

The experimental web modal implementation is removed. Follow Build custom web modals to render modal overlays with a custom navigator. Custom implementations can import NativeStackView from expo-router for the native stack view.

Replace freezeOnBlur

The freezeOnBlur option has no effect in SDK 58. Remove it from your screen configuration. To preserve state while cleaning up effects after a screen loses focus, set activityEnabled on the navigator or a declared screen. A value of 1 hides the screen content as soon as another screen is focused:

app/_layout.tsx
import { Stack } from 'expo-router'; export default function RootLayout() { return ( <Stack> {/* Uses the stack default and hides content when two screens are above it. */} <Stack.Screen name="feed" activityEnabled /> {/* Hides content as soon as another screen is focused. */} <Stack.Screen name="account" activityEnabled={1} /> </Stack> ); }

When activityEnabled is true, a stack hides a screen's content once two screens sit above that screen. Tabs and drawers hide a screen's content as soon as it loses focus. Stack navigators and screens also accept a positive number to override that threshold.

To hide only part of a screen, wrap that content in NavigationAwareActivity instead. Its hideWhenNestedAtLevel prop uses the same threshold behavior and defaults to 2.

Install optional native dependencies

SDK 58 makes expo-symbols and @expo/ui optional peer dependencies of Expo Router. Install only the dependency required by the APIs your app uses:

Terminal
# Android md icons in native tabs
npx expo install expo-symbols
# Android Stack.Toolbar
npx expo install @expo/ui
# Android md icons in native tabs
yarn expo install expo-symbols
# Android Stack.Toolbar
yarn expo install @expo/ui
# Android md icons in native tabs
pnpm expo install expo-symbols
# Android Stack.Toolbar
pnpm expo install @expo/ui
# Android md icons in native tabs
bun expo install expo-symbols
# Android Stack.Toolbar
bun expo install @expo/ui

After installing either dependency, rebuild development builds that use the affected native API.

Prevent a screen from being removed

Existing usePreventRemove calls only need changes when their callback repeats or replaces the blocked action. To continue the exact blocked action, set the prevention condition to false and call the callback's repeat function.

Use usePreventRemove from expo-router to block removal while a screen has unsaved data:

app/edit-profile.tsx
import { usePreventRemove } from 'expo-router'; import { useState } from 'react'; import { Alert, Button } from 'react-native'; export default function EditProfile() { const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); usePreventRemove(hasUnsavedChanges, ({ repeat }) => { Alert.alert('Discard changes?', 'Your changes have not been saved.', [ { text: 'Keep editing', style: 'cancel' }, { text: 'Discard', style: 'destructive', onPress: () => { setHasUnsavedChanges(false); repeat(); }, }, ]); }); return <Button title="Save" onPress={() => setHasUnsavedChanges(false)} />; }

To navigate somewhere other than the blocked destination, retain the disablePrevention function returned by usePreventRemove. Set the prevention condition to false, call disablePrevention(), and then navigate. Do not dispatch from a raw removePrevented listener while prevention is still active because the action can be blocked again.

Advanced migrations

Update navigation.dispatch

navigation.dispatch and navigation helper actions are now queued until after the current React commit. Use navigation.dispatchSync(action) only when an integration must apply an action immediately.

Dispatch functions are no longer supported. Read the state first, compute an action, and dispatch the action object:

navigation.ts
1navigation.dispatch(state =>
2 CommonActions.reset({
3 ...state,
4 index: 0,
5 routes: [state.routes[0]],
6 })
7);
1const state = navigation.getState();
2const action = CommonActions.reset({
3 ...state,
4 index: 0,
5 routes: [state.routes[0]],
6});
7
8navigation.dispatch(action);

The read and deferred dispatch are not atomic. Use dispatchSync(action) deliberately if other navigation work cannot happen between them.

Replace navigationKey

Layout Screen and Group components no longer accept navigationKey. There is no direct replacement. Use route and layout identity, protected routes, or explicit href navigation based on why the key was used.

Replace beforeRemove and __unsafe_action__

The beforeRemove and __unsafe_action__ events are removed. Use removed to observe a completed removal and removePrevented to observe an action blocked by usePreventRemove.

The removed event fires after the route unmounts. Defer cleanup of its listener so it can receive that event:

useEffect(() => { const unsubscribe = navigation.addListener('removed', event => { logRemoval(event.data.action); }); return () => queueMicrotask(unsubscribe); }, [navigation]);

Do not update the removed screen's state from this listener because the screen has already unmounted.

Move useNavigation inside a navigator

useNavigation now throws when it is called outside a navigator. If you render ExpoRoot manually, this includes components rendered by its wrapper prop. Move the hook into a route or layout rendered inside the navigation tree.

Read navigation state

Navigation state is an implementation detail. Prefer usePathname, useSegments, and useGlobalSearchParams when you need URL or route information.

Handle optional type and history

The navigation state type is optional for custom routers. history is optional in tab and drawer state. Add fallback values before reading them:

const history = state.history ?? []; if (state.type === 'tab') { // Handle tab-specific state. }

Read stack preloaded routes from routes

StackNavigationState.preloadedRoutes is removed. Preloaded routes are appended to state.routes after the focused index:

const activeRoutes = state.routes.slice(0, state.index + 1); const preloadedRoutes = state.routes.slice(state.index + 1);

Use routeNames for tab order

TabNavigationState.preloadedRouteKeys is removed. Preloaded tabs are unfocused entries in state.routes with isPreloaded: true, and lazy routes may be absent from that array. Iterate over state.routeNames when you need the declared tab order:

for (const routeName of state.routeNames) { const route = state.routes.find(route => route.name === routeName); // `route` is undefined when the lazy route has not been created. }

Remove drawer default

DrawerNavigationState.default is removed. In a component, use useDrawerStatus from expo-router/drawer to read whether the drawer is open or closed. getDrawerStatusFromState is deprecated and now requires the router's default status as its second argument.

Include routeKeySeq in complete state

Persisted initial state must be complete. Every nested state needs a state key, route keys, routeKeySeq, routeNames, index, and stale: false. Expo Router throws when it receives incomplete persisted initial state.

Navigation state returned by a custom router extension still contains routeKeySeq, but the extension does not update it directly. Preserve the current state with ...state and use the nextKey function provided by extendRouter. The wrapper writes the updated sequence into the returned state.

CommonActions.reset also requires state with stale: false, but the router generates omitted route keys and fills an omitted routeKeySeq from the current state.

Use ...state to preserve the current navigator's keys, route names, and key sequence when resetting its state. See the example in Update navigation.dispatch.

Persisted state must retain valid keys and route names at every nested level.

Update custom routers

In SDK 58, create custom routers by extending StackRouter or TabRouter with extendRouter or extendRouterActions. Expo Router builds the complete initial state, while the wrapper preserves the base router's behavior and state invariants.

Update the router interface

Move the parts of an existing custom router that differ from its base router into an extension. Members that the extension does not override are inherited:

SDK 57 APISDK 58 migration
getInitialStateRemove it. Expo Router builds initial state.
getRehydratedStateRemove it. Persisted data must provide complete state.
getStateForRouteNamesChangeInherit the base behavior, or handle ROUTE_NAMES_CHANGED in the extension.
routeParamListRemove it from RouterConfigOptions. Put parameter defaults in route code.
RouterActionOptionsUse RouterConfigOptions in getStateForAction.
Other router membersInherit them from the base router unless the extension changes the behavior.

The base stack and tab routers handle PUSH. If an extension overrides getStateForAction, delegate actions that it does not handle to the provided baseRouter.

Return the affected route key

getStateForAction now returns the next state together with the key of the route affected by the action:

custom-router.ts
1return nextState;
1// `affectedRoute` is selected by this action's handling logic.
2return {
3 state: nextState,
4 affectedRouteKey: affectedRoute?.key,
5};

Set affectedRouteKey to the route selected or changed by that specific action, which is not always the focused route. Return null when the router cannot handle an action.

Extend a built-in router

extendRouter receives the base router and a nextKey function. nextKey generates deterministic route keys, and the wrapper writes the updated routeKeySeq into returned state:

import { attachRouteState, extendRouter, StackRouter, type CommonNavigationAction, type StackActionType, } from 'expo-router'; type CustomAction = { type: 'CUSTOM-ACTION'; payload: { name: string; params?: object }; }; const CustomStackRouter = extendRouter(StackRouter, ({ baseRouter, nextKey }) => ({ getStateForAction( state, action: CommonNavigationAction | StackActionType | CustomAction, config ) { if (action.type === 'CUSTOM-ACTION') { if (!state.routeNames.includes(action.payload.name)) { return null; } const route = attachRouteState( { key: nextKey(action.payload.name), name: action.payload.name, params: action.payload.params, }, action ); const activeRoutes = state.routes.slice(0, state.index + 1); const preloadedRoutes = state.routes.slice(state.index + 1); return { state: { ...state, index: activeRoutes.length, routes: [...activeRoutes, route, ...preloadedRoutes], }, affectedRouteKey: route.key, }; } return baseRouter.getStateForAction(state, action, config); }, }));

Use extendRouterActions when only getStateForAction needs customization. Return undefined from its reducer to delegate an action. When overriding getStateForAction with extendRouter, delegate unhandled actions to baseRouter as shown above.

Make the router type optional when appropriate

extendRouter inherits the base router's type. If an extension changes the state to require a different type, pass that type in the wrapper's options. An extension whose state type is optional can omit it.

Update custom navigators

For new custom integrations, use createStandardRouterNavigator in an app or integrateWithRouter in a reusable library. These APIs use the standard-navigation contract to keep navigator state, descriptors, actions, and events aligned with Expo Router.

src/expo-router.ts
1import { withLayoutContext } from 'expo-router';
2import { createNavigator } from './navigator';
1import { createStandardRouterNavigator, TabRouter } from 'expo-router';
2import { TabNavigatorContent } from './navigator';
33
4export const Tabs = withLayoutContext(createNavigator().Navigator);
4export const Tabs = createStandardRouterNavigator(TabNavigatorContent, TabRouter);

withLayoutContext remains supported for existing React Navigation navigators. Remove its former third useOnlyUserDefinedScreens argument if your integration uses it. Filesystem routes remain registered. Use descriptor routeSource when navigator UI needs to distinguish layout-declared routes from filesystem routes.

The example assumes TabNavigatorContent has been converted from a React Navigation navigator factory to a component that accepts NavigatorContentProps. Library authors should instead create a framework-independent navigator with createStandardNavigator from standard-navigation, then pass that navigator to integrateWithRouter.

The createStackNavigator export is removed from expo-router/js-stack. This does not affect createNativeStackNavigator from expo-router/native-stack, which remains available. For new stack integrations, prefer a standard navigator with createStandardRouterNavigator, or pass it to integrateWithRouter with the matching props helper:

NavigatorcreateProps helper
Custom stackcreateBaseStackProps
Expo Router JavaScript stackcreateJSStackProps
Expo Router native stackcreateNativeStackProps
Custom tabcreateBaseTabProps
Expo Router JavaScript tabscreateJSTabsProps
Expo Router JavaScript top tabscreateJSTopTabsProps
Expo Router native tabscreateNativeTabsProps

Import the base and native stack helpers from expo-router. Import the navigator-specific helpers from the corresponding expo-router/js-stack, expo-router/js-tabs, expo-router/js-top-tabs, or expo-router/native-tabs entry point.

The key on descriptor.route can be undefined for a declared route that does not yet have a live state route. Update custom navigator code to handle that case.

See Custom navigators for standard navigator concepts.

Review removed expo-router/react-navigation exports

The following compatibility APIs are removed or changed in SDK 58. Imports that remain supported are listed by the expo-router/react-navigation entry point.

Removed APIReplacementChange
UNSTABLE_UnhandledLinkingContextNo app-level replacement. Expo Router owns unhandled link processing.#49616
BaseNavigationContainer, NavigationContainerLet Expo Router or ExpoRoot own the navigation container.#49587, #48760
Root options event, DocumentTitleOptions, documentTitleUse Expo Router Head or a <title> element for web metadata.#49590
onStateChange on container propsPrefer Expo Router state hooks, or listen for the navigation ref's state event.#49588
NavigationIndependentTree, useNavigationIndependentTreeUse NavigationContainer from @react-navigation/native for an isolated embedded navigation tree.#49172
React Navigation Link, LinkProps, useLinkPropsUse Link and LinkProps from expo-router with an href.#48895
navigateDeprecated, navigationInChildEnabledNavigate to a complete href with useRouter.#49102
NavigatorScreenParams, getActionFromState, LinkingOptions.getActionFromStateUse complete hrefs and let Expo Router resolve navigation state.#49297
resetRoot on navigation container refsUse router.replace or dispatch CommonActions.reset with complete state.#49297
beforeRemove, __unsafe_action__Use usePreventRemove, removePrevented, and removed.#49408
PreventRemoveContext, usePreventRemoveContext, PreventRemoveProviderUse usePreventRemove. Expo Router owns the provider.#49408, #48347
Router.getInitialState, Router.getRehydratedStateLet Expo Router build initial state and return complete state from custom routers.#48783, #49297
Router.getStateForRouteNamesChangeHandle ROUTE_NAMES_CHANGED in getStateForAction.#48479
RouterActionOptions, RouterConfigOptions.routeParamListUse RouterConfigOptions without routeParamList.#48783
DrawerNavigationState.defaultUse useDrawerStatus, or pass a default status to the deprecated getDrawerStatusFromState.#48750
Static navigation APIs and typesUse Expo Router file routes and layouts.#48071
LinkingOptions.enabledRemove the option. Expo Router owns linking.#49103
UNSTABLE_routeNamesChangeBehaviorUse protected route redirects and explicit href navigation.#47985
useOnlyUserDefinedScreensRemove the option. All filesystem routes remain registered.#47983