HomeGuidesReferenceLearn
ArchiveExpo SnackDiscord and ForumsNewsletter

Apple Handoff

Learn how to seamlessly continue app navigation across Apple devices with Expo Router and Apple Handoff.


Apple Handoff is a feature that enables users to continue browsing your app or website on another device. Expo Router v2 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.

Platform Compatibility

Android DeviceAndroid EmulatoriOS DeviceiOS SimulatorWeb

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 activitycontinuation object.
  • Handoff requires the expo-router/head component 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.

public/.well-known/apple-app-site-association
{
  "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 webcredentials object is optional but recommended.

You can use the following command to generate the apple-app-site-association file based on your app config:

Terminal
npx setup-safari

See Universal Link debugging guide to test handoff in development.

Expo Head setup

Ensure you set the Handoff origin in your app.config.js 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.

app.config.js
// 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=developer suffix when testing handoff to native.

After configuring the app config, regenerate your native project with the following command:

Terminal
npx expo prebuild -p ios

In 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:

app/index.js
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 tagsDescription
expo:handoffSet 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:descriptionSet the description for the NSUserActivity this is unused with handoff.
og:urlSet 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:

app/index.tsx
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.

  1. Open your native application on your device.
  2. Navigate to a route in the app that supports handoff which is rendering the <Head /> element from Expo Router.
  3. To switch to your Mac and click the app's Handoff icon in the Dock.
  4. 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 setup, 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=developer suffix when testing handoff to native.
  • Also be sure you're not using the local development server URL. For example, http://localhost:8081 as 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 activitycontinuation field.
  • We've observed that in iOS 16.3.1 and macOS 13.0 (Ventura), bundle identifiers starting with app. and io. will sometimes not trigger the native app to show up in the iOS task switcher. Use com. 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_SUBDOMAIN environment 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.