> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userpilot.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom URL Scheme with Expo

> Register and handle the Userpilot custom URL scheme in an Expo app to preview draft experiences and route push notification taps.

The Userpilot React Native module supports a custom URL scheme that lets you preview experiences on a real device before publishing them, and that carries push notification taps back into your app. You can use it in an Expo app.

Expo handles the native registration for you from `app.json`, so the setup is different from a bare React Native project: you declare the scheme in configuration, then read incoming links with `expo-linking`.

***

## Register the custom URL scheme

Add a [`scheme`](https://docs.expo.dev/versions/latest/config/app/#scheme) property to `app.json` with a value of `userpilot-APP_TOKEN`, replacing `APP_TOKEN` with your app's Userpilot token from your [Environments Page](https://run.userpilot.io/environment).

For example, if your token is `NX-12345678`, your scheme value is `userpilot-nx-12345678`.

On Android, also register the `sdk` host through [`android.intentFilters`](https://docs.expo.dev/versions/latest/config/app/#intentfilters). Merge the following into your existing `app.json`, preserving your other intent filters. If you use the Userpilot Expo config plugin, use the plugin setup below to generate this filter instead.

```json theme={null}
{
  "expo": {
    "scheme": "userpilot-APP_TOKEN",
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "data": {
            "scheme": "userpilot-APP_TOKEN",
            "host": "sdk"
          },
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}
```

The Android filter generates `<data android:scheme="userpilot-APP_TOKEN" android:host="sdk" />`. Userpilot validates that incoming preview and push-notification URLs use the `sdk` host.

The scheme **must** be `userpilot-` followed by your token, lowercased. This is the scheme the SDK builds its links with, so any other value means neither previews nor notification taps reach your app.

### If you use the Userpilot Expo config plugin

If you use `@userpilot/expo-config` for push notifications, pass the same scheme value to the plugin. During prebuild, it adds the Android intent filter with `android:host="sdk"` automatically; no separate host option or manual `android.intentFilters` entry is needed for Userpilot:

```json theme={null}
{
  "expo": {
    ...
    "scheme": "userpilot-APP_TOKEN",
    "plugins": [
      [
        "@userpilot/expo-config",
        {
          "scheme": "userpilot-APP_TOKEN"
        }
      ]
    ]
  }
}
```

Keep the two values identical. See [Expo push notifications](../mobile-expo-push-notification) for the rest of the plugin setup.

<Warning>
  **A scheme change needs a new build**

  Registering a URL scheme is a native change, so it doesn't take effect in an existing build or in Expo Go. Create a new [Development Build](https://docs.expo.dev/develop/development-builds/introduction/) or EAS build after editing `app.json`.
</Warning>

***

## Handle the custom URL scheme

Install [`expo-linking`](https://docs.expo.dev/versions/latest/sdk/linking), which parses deep links into your app:

```bash theme={null}
npx expo install expo-linking
```

Use the `useURL()` hook to watch for incoming links and pass each one to `Userpilot.didHandleURL(url)`. It resolves to `true` when the SDK recognized and handled the link, and `false` when your app should handle it.

```jsx theme={null}
import { useEffect } from 'react';
import { useURL } from 'expo-linking';
import * as Userpilot from '@userpilot/react-native';

const url = useURL();

useEffect(() => {
  if (!url) return;

  Userpilot.didHandleURL(url).then((userpilotDidHandleURL) => {
    if (!userpilotDidHandleURL) {
      // Handle a non-Userpilot URL
    }
  });
}, [url]);
```

`useURL()` reports the launch link as well as links that arrive later, so this one hook covers both a cold start and a warm one.

<Note>
  This single handler covers push notification taps too. On Android the SDK posts each notification with a matching deep link, so a tap arrives here as a normal link. On iOS notification taps are handled natively by the subscriber the config plugin registers and never reach this hook. You do **not** need `expo-notifications` for Userpilot notifications.
</Note>

### Compatibility with Expo Router

Expo Router assumes every incoming URL targets a page inside your app, so it will try to route Userpilot links. To opt them out, create a `+native-intent.tsx` file at the top level of your project's **app** directory and return `null` for Userpilot links. See the [Expo Router documentation](https://docs.expo.dev/router/advanced/native-intent/#rewrite-incoming-native-deep-links) for details.

```jsx theme={null}
export function redirectSystemPath({ path, initial }) {
  // If the incoming link starts with the Userpilot URL scheme, skip having the router handle it
  if (path?.startsWith('userpilot')) {
    return null;
  }

  // Otherwise proceed as normal
  return path;
}
```

***

## Supported links

The SDK only claims a URL when the scheme matches your token and the host is `sdk`. Every other URL is passed back to your app.

| **Link** | **Purpose** |
| - | - |
| `userpilot-{token}://sdk/experience_preview/{experience_id}` | Previews a draft Flow or Survey. This is the link behind the Builder's QR code. |
| `userpilot-{token}://sdk/notification` | Routes an Android push notification tap to the SDK. Query parameters carry the notification payload. |

***

## Preview an experience with a QR code

Userpilot can render a draft experience on your device before you publish it, so you can check layout, copy, and behavior on a real screen. Preview is supported for **Flows** and **Surveys**.

The preview is delivered over the custom URL scheme. It works once your app is installed on the device and the scheme is registered and handled as described above.

<Steps>
  <Step title="Open the experience">
    Open a Flow or Survey in the Experience Builder and enter **Edit Mode**.
  </Step>

  <Step title="Start the preview">
    Select **Preview** in the top-right corner of the builder. A popup with a **QR code** appears.
  </Step>

  <Step title="Scan the QR code">
    Scan the code with your device camera. Your app opens and the experience appears.
  </Step>
</Steps>

Points to keep in mind:

* You don't need to apply or save your changes before previewing them.
* The experience doesn't need to be live.
* A preview bypasses the experience's targeting and frequency settings, so it appears even when the current user wouldn't normally qualify for it.
* Once you close the experience, the app resumes normal behavior.

<Warning>
  **Previewing the same experience twice in a row**

  `useURL()` only fires when the link *value* changes, so scanning the QR code for an experience you just previewed produces the same URL and the hook doesn't run again. Rather than scanning repeatedly, shake your device while the preview is open to reload it with the latest changes from the Builder.
</Warning>

***

## Test push notifications

You can send a test push to a specific user from the Userpilot dashboard and verify delivery and tap handling on a real device without sending anything to your wider audience. The notification doesn't need to be live, so you can test it while it's still a draft.

* Test pushes work in both the staging and production environments.
* Test pushes are **not** counted as real sends, and they're excluded from analytics.
* Test pushes bypass the notification's targeting and frequency settings.

For the dashboard steps, see [Mobile push notifications](/in-app-engagement/mobile-content/mobile-push-notifications).

Because a notification tap arrives as a deep link on Android, a test push also exercises your `didHandleURL` handling there. If the notification appears but tapping it does nothing, check that the plugin `scheme` prop matches your top-level `scheme`.

***

## Test a deep link from the command line

Open a link directly to confirm your scheme is registered and your handler runs, without going through the Builder:

```bash theme={null}
# Android
adb shell am start -a android.intent.action.VIEW \
  -d "userpilot-nx-12345678://sdk/experience_preview/12345"

# iOS Simulator
xcrun simctl openurl booted "userpilot-nx-12345678://sdk/experience_preview/12345"
```

Replace the scheme with your own token and `12345` with a real experience ID.

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="Nothing happens when I scan the QR code">
    Confirm the `scheme` in `app.json` matches your token exactly, lowercased. Then confirm you're running a build created **after** you added it: a scheme is a native change, so it's absent from earlier builds and from Expo Go.
  </Accordion>

  <Accordion title="The second preview of the same experience does nothing">
    Expected. `useURL()` fires only when the link value changes, and re-scanning the same experience yields an identical URL. Shake the device while the preview is open to reload it instead.
  </Accordion>

  <Accordion title="Expo Router opens a blank screen instead of the experience">
    Expo Router is trying to resolve the Userpilot link as an app route. Add the `+native-intent.tsx` file shown above so Userpilot links bypass the router.
  </Accordion>

  <Accordion title="Tapping an Android push notification does nothing">
    Notification taps travel over this same scheme on Android. Check that the `scheme` prop you passed to `@userpilot/expo-config` is identical to your top-level `scheme`, since the plugin uses it to build the Android intent filter.
  </Accordion>

  <Accordion title="Preview works in production but not in staging">
    Each environment has its own token, and the scheme follows the token. Confirm the staging build registers the scheme for the token it initializes the SDK with.
  </Accordion>
</AccordionGroup>

<Frame>
  [**For any questions or concerns please reach out to support@userpilot.com**](mailto:support@userpilot.com)
</Frame>
