Custom navigators
Learn how to build your own navigator in Expo Router and how library authors can integrate an existing navigator with the router.
The custom navigator API described on this page is in alpha and available in SDK 56 and later. The API is subject to breaking changes.
Expo Router ships with navigators for the most common patterns — Stack, Tabs, Native tabs, and Drawer. When none of them fit, you can build your own navigator and use it as a layout, with file-based routing, deep linking, and typed routes working exactly as they do for the built-in navigators.
Choose the entry point that matches your goal:
- App developers building a navigator for one app use
unstable_createStandardRouterNavigator. - Library authors shipping a reusable navigator for both Expo Router and React Navigation use
unstable_integrateWithRouter.
For a stack navigator that renders routes as web modal overlays, see Build custom web modals.
Create a navigator in your app
Use unstable_createStandardRouterNavigator to turn a content component into a navigator you can render as a layout. It takes two required arguments:
NavigatorContent: a component that renders your navigator's UI. It receives the current navigationstate, thedescriptorsfor each screen,actionsto navigate, and anemitterto send events.router: the routing behavior to use. ImportStackRouterfor stack-like navigation orTabRouterfor tab-like navigation fromexpo-router.
The following example builds a minimal tab navigator:
import {
unstable_createStandardRouterNavigator,
TabRouter,
type NavigatorContentProps,
} from 'expo-router';
import { Pressable, Text, View } from 'react-native';
// The first type argument is the options you can set per screen
type TabsContentProps = NavigatorContentProps<{ title?: string }>;
function TabsContent({ state, descriptors, actions }: TabsContentProps) {
const focusedRoute = state.routes[state.index];
return (
<View style={{ flex: 1 }}>
{/* Render the screen for the focused route. */}
<View style={{ flex: 1 }}>{descriptors[focusedRoute.key].render()}</View>
{/* A simple tab bar. */}
<View style={{ flexDirection: 'row' }}>
{state.routes.map(route => (
<Pressable
key={route.key}
style={{ flex: 1, padding: 16 }}
onPress={() => actions.navigate(route.name)}>
<Text>{descriptors[route.key].options.title ?? route.name}</Text>
</Pressable>
))}
</View>
</View>
);
}
export const Tabs = unstable_createStandardRouterNavigator(TabsContent, TabRouter);The returned navigator has a .Screen child for declaring screens, so you can use it in a _layout file like any other layout:
import { Tabs } from '../components/Tabs';
export default function Layout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: 'Home' }} />
<Tabs.Screen name="settings" options={{ title: 'Settings' }} />
</Tabs>
);
}What NavigatorContent receives
| Property | Description |
|---|---|
state | The current navigation state: { index, routes }, where each route has a key, name, params, and an href. |
descriptors | A map keyed by route.key. Each descriptor exposes the screen's resolved options and a render() function that renders the screen. |
actions | Functions to change the navigation state: navigate(name, params?) and back(). |
emitter | An object with an emit() method for sending events to screens. |
Typed events
If your navigator emits events, declare them in the second type argument to NavigatorContentProps. Each key is an event name, and its value describes the event's data and whether it canPreventDefault. emitter.emit is then typed against that map — unknown event names and mismatched payloads are rejected:
type TabsContentProps = NavigatorContentProps<
{ title?: string },
{ tabPress: { data: undefined; canPreventDefault: true } }
>;
function TabsContent({ emitter }: TabsContentProps) {
emitter.emit({ type: 'tabPress', canPreventDefault: true });
// ...
}unstable_createStandardRouterNavigator infers the event map from the component, so you do not pass it again at the call site. Omit the second type argument for a navigator that emits no events.
Options
Both unstable_createStandardRouterNavigator and unstable_integrateWithRouter accept an optional options object as their third argument:
useOnlyUserDefinedScreens: whentrue, only screens you declare with<Navigator.Screen>are rendered. Expo Router ignores routes discovered from the filesystem that you did not declare. Defaults tofalse, where Expo Router also renders routes discovered from the filesystem alongside your declared screens.createProps: derives extra props forNavigatorContentfrom the underlying router state. Use this for router-specific information that is not part of the standardstateandactions.
export const Tabs = unstable_createStandardRouterNavigator(TabsContent, TabRouter, {
useOnlyUserDefinedScreens: true,
createProps: ({ state, dispatch }) => ({
activeRouteKey: state.routes[state.index].key,
preload: (name: string) => dispatch({ type: 'PRELOAD', payload: { name } }),
}),
});Declare the props returned by createProps in the third type argument to NavigatorContentProps so NavigatorContent receives them in a typed way:
type TabsContentProps = NavigatorContentProps<
{ title?: string },
// No custom events in this example.
Record<string, never>,
// Props injected by `createProps`.
{ activeRouteKey: string; preload: (name: string) => void }
>;
function TabsContent({ activeRouteKey, preload }: TabsContentProps) {
// ...
}
createPropsreceives the raw Expo Routerstateanddispatch. These are internal and may have breaking changes between releases, so prefer thestateandactionspassed toNavigatorContentwhen they are enough. If something you need to build your navigator is missing from the standardstate,actions, oremitter, open an issue on GitHub.
Integrate an existing navigator (library authors)
The standard navigator API
The NavigatorContent component shown above is a standard navigator.
- It implements a minimal, framework-agnostic contract defined by the 1 package.
- The
state,descriptors,actions, andemitteryour content receives are exactly the same API as the in-app navigator above. - The only difference is who creates the navigator.
unstable_createStandardRouterNavigator is a shortcut that calls createStandardNavigator (from standard-navigation) for you and integrates the result with Expo Router in one step. As a library author, call createStandardNavigator yourself and keep a reference to the navigator:
import { createStandardNavigator } from 'standard-navigation';
import { TabsContent } from './TabsContent';
// Framework-agnostic: this navigator targets the standard contract, not any one host.
// The first type argument is the per-screen options; the second is the event map.
export const navigator = createStandardNavigator<
{ title?: string },
{ tabPress: { data: undefined; canPreventDefault: true } }
>(TabsContent);Because TabsContent and navigator depend only on the standard contract, the same code runs on Expo Router, React Navigation, or any other host that implements it. You write the navigator once and ship a thin integration entry point per framework.
Integrate with Expo Router
Wire your navigator into Expo Router with unstable_integrateWithRouter:
import { unstable_integrateWithRouter, TabRouter } from 'expo-router';
import { navigator } from './index';
export const Tabs = unstable_integrateWithRouter(navigator, TabRouter);The returned component works exactly like the one from unstable_createStandardRouterNavigator, including the .Screen child and the same options.
Library entry points
Keep the navigator content and the standard navigator framework-agnostic, then expose one entry point per framework so consumers import the integration that matches their app:
.
src
- TabsContent.tsxNavigator UI implementing the standard navigator API
- index.tsRoot entry — exports the framework-agnostic navigator
- react-navigation.tsReact Navigation entry — integrates the same navigator
- expo-router.tsExpo Router entry — unstable_integrateWithRouter(navigator, ...)
- package.jsonMaps subpath exports to each framework entry
Map each entry point to a subpath export in your library's package.json, pointing at your build output:
{
"exports": {
".": {
"types": "./lib/typescript/index.d.ts",
"default": "./lib/module/index.js"
},
"./react-navigation": {
"types": "./lib/typescript/react-navigation.d.ts",
"default": "./lib/module/react-navigation.js"
},
"./expo-router": {
"types": "./lib/typescript/expo-router.d.ts",
"default": "./lib/module/expo-router.js"
}
}
}Consumers then import the integration for their framework (for example, import { Tabs } from 'my-tabs/expo-router') while you maintain the navigator logic in one place.
React Navigation integration — Learn how to integrate the same standard navigator with React Navigation, and read the contract that defines the state, descriptors, actions, and emitter your NavigatorContent receives.
Apple Handoff
iOS (device only), Web
Apple Handoff is a feature that enables users to continue browsing your app or website on another device. Expo Router automates all of the runtime routing for this feature. However, the one-time configuration must be set up manually.
In Expo Router, the underlying iOS API (NSUserActivity) requires a webpageUrl which the OS recommends as the current URL for switching to your app. The expo-router/head component has an optional native module that can automatically set the webpageUrl to the currently focused route in Expo Router.
Setup
The following restrictions and considerations are important:
- Handoff is Apple-only.
- Handoff can not be used in the Expo Go app as it requires build-time configuration.
- Handoff requires universal links to be configured, at least on iOS, and contain the
activitycontinuationobject. - Handoff requires the
expo-router/headcomponent to be used on each page that you want to support, or in the root layout if you want all pages to be continuous.
To ensure that the public/.well-known/apple-app-site-association file is configured correctly, it must include the activitycontinuation key with an apps array that contains your app's bundle ID and Team ID formatted as <APPLE_TEAM_ID>.<IOS_BUNDLE_ID>. For example, QQ57RJ5UTD.app.expo.acme where QQ57RJ5UTD is the Team ID and app.expo.acme is the bundle identifier.
{
"applinks": {
"details": [
{
"appIDs": ["<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>"],
"components": [
{
"/": "*",
"comment": "Matches all routes"
}
]
}
]
},
"activitycontinuation": {
"apps": ["<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>"]
},
"webcredentials": {
"apps": ["<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>"]
}
}The
webcredentialsobject is optional but recommended.
You can use the following command to generate the apple-app-site-association file based on your app config:
bunx setup-safariSee Test the deep link guide to test handoff in development.
Expo Head setup
Ensure you set the Handoff origin in your app.config.tsx file using the expo-router config plugin. This is the URL that will be used for the webpageUrl when the user switches to your app.
// Be sure to change this to be unique to your project.
process.env.EXPO_TUNNEL_SUBDOMAIN = 'bacon-router-sandbox';
const ngrokUrl = `${process.env.EXPO_TUNNEL_SUBDOMAIN}.ngrok.io`;
/** @type {import('expo/config').ExpoConfig} */
module.exports = {
// ...
ios: {
associatedDomains: [
`applinks:${ngrokUrl}`,
`activitycontinuation:${ngrokUrl}`,
`webcredentials:${ngrokUrl}`,
// Add additional production-URLs here.
// `applinks:example.com`,
// `activitycontinuation:example.com`,
// `webcredentials:example.com`,
],
},
plugins: [
[
'expo-router',
{
// Note: The URL must start with "https://" in "headOrigin"
headOrigin:
process.env.NODE_ENV === 'development'
? `https://${ngrokUrl}`
: 'https://my-website-example.com',
},
],
],
};Do not use the development-only
?mode=developersuffix when testing handoff to native.
After configuring the app config, regenerate your native project with the following command:
bun expo prebuild -p iosIn development, you must start the website before installing the app on your device.
- This is because when you install the app, the OS will trigger Apple's servers to ping your website for the .well-known/apple-app-site-association file.
- If the website is not running, the OS will not be able to find the file and handoff will not work.
- If this happens, rebuild the native app with
npx expo run:ios -d.
Usage
In any route that you want to support handoff, use the Head component from expo-router/head:
import Head from 'expo-router/head';
import { Text } from 'react-native';
export default function App() {
return (
<>
<Head>
<meta property="expo:handoff" content="true" />
</Head>
<Text>Hello World</Text>
</>
);
}Meta tags
The expo-router/head component supports the following meta tags:
| Meta tags | Description |
|---|---|
expo:handoff | Set to true to enable handoff for the current route. Defaults to false. (iOS only) |
og:title and <title> | Set the title for the NSUserActivity this is unused with handoff. |
og:description | Set the description for the NSUserActivity this is unused with handoff. |
og:url | Set the URL that should be opened when the user switches to your app. Defaults to the current URL in-app with headOrigin prop in the expo-router config plugin, as the baseURL. Passing a relative path will append the headOrigin to the path. |
You may want to switch the values between platforms, for that you can use Platform.select:
import Head from 'expo-router/head';
export default function App() {
return (
<Head>
<meta
property="og:url"
content={Platform.select({ web: 'https://expo.dev', default: null })}
/>
</Head>
);
}Debugging
Ensure your Apple devices have Handoff enabled. You can test this by following the steps below but substituting your app with Safari.
- Open your native application on your device.
- Navigate to a route in the app that supports handoff which is rendering the
<Head />element from Expo Router. - To switch to your Mac and click the app's Handoff icon in the Dock.
- To switch to your iPhone or iPad, open the App Switcher, and tap the app banner at the bottom of the screen.
If you only see the Safari icon in your iPhone's App Switcher, then handoff is not working.
Troubleshooting
You can test the Apple App Site Association file (public/.well-known/apple-app-site-association) by using a validator such as, AASA Validator.
If you're having issues, the best thing you can do is enable the most aggressive handoff settings in your app.
- This ensures that any possible route is linkable.
- You can do this by making sure that public/.well-known/apple-app-site-association file matches all routes:
{
"applinks": {
"details": [
{
"appIDs": ["<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>"],
"components": [
{
"/": "*",
"comment": "Matches all routes"
}
]
}
]
}
}In the application, ensure you are not rendering the <Head /> element conditionally (for example, in an if/else block), it must be rendered on every page that you want to support handoff. We recommend adding it to the Root Layout component to ensure every route is linkable while debugging.
Ensure you can access the Ngrok URL (for example, via the browser), before installing the app on your device. If you can't access the URL, the OS will not be able to find the file and handoff will not work.
npx expo run:ios and Xcode will both codesign your app when associated domains is set up, this is required for handoff and universal links to work.
Handoff between your Mac and iPhone/iPad is not supported in the Expo Go app. You must build and install your app on your device.
If you see the Safari icon in the App Switcher on your iPhone, then it means handoff is not working.
- Ensure you are not using the
?mode=developersuffix when testing handoff to native. - Also be sure you're not using the local development server URL. For example,
http://localhost:8081as this cannot be used as a valid app site association link, open the running Ngrok URL in your browser to test. - Ensure your public/.well-known/apple-app-site-association file contains the
activitycontinuationfield. - We've observed that in iOS 16.3.1 and macOS 13.0 (Ventura), bundle identifiers starting with
app.andio.will sometimes not trigger the native app to show up in the iOS task switcher. Usecom.as the first part of your bundle identifier.
Your public/.well-known/apple-app-site-association must be served from a secure URL (HTTPS).
- If you are using a development tunnel, you must use the
EXPO_TUNNEL_SUBDOMAINenvironment variable to configure the subdomain for your development tunnel. - The tunnel is required for testing in development because you need SSL to use universal links, Expo CLI provides built-in support for this by running
npx expo start --tunnel.
Check your ios/project/project.entitlements file, under the com.apple.developer.associated-domains key.
- This should contain the same domains as your web server/website.
- The URL cannot contain a protocol (
https://) or additional pathname, query parameters, or fragments.
Still stuck
This is an important but very difficult feature to set up. Expo Router automates many of the moving parts, Expo CLI automates much of the configuration and hosting. However, hardware settings can still be misconfigured.
If all else fails, you can try to debug the issue by following the steps in the Apple Docs. Note that:
- "Representing user activities as instances of
NSUserActivity." is performed by the Expo Head native module. - "Updating the activity instances as the user performs actions in your app." is performed by mounting/rendering the
<Head />component with the meta tag<meta property="expo:handoff" content="true" />inside. - "Receiving activities from Handoff in your app on other devices." is performed by an App Delegate Subscriber in the Expo Head native module. It is used to redirect you to the correct route when you handoff to your native app.
Known issues
Handoff from web to native does not support client-side routing.
- This means the URL presented in the App Switcher will be the URL of the page you were on when you clicked the link, or reloaded the page.
- It is a limitation of the web platform and not something that can be fixed by Expo Router.
Sources
The original Expo docs for this page:
- Custom navigators (
source-of-truth/router/advanced/custom-navigators.md) - Apple Handoff (
source-of-truth/router/advanced/apple-handoff.md)