A phone app has URLs too. A link in an email, a push notification, a payment provider returning the customer after a redirect — all of them need to open your app at a particular screen, and all of them fail silently if you have not set it up.
Registering a scheme
The simplest form is a custom scheme, and it is one line of configuration:
const config: ExpoConfig = {
name: 'StayHub Pizza',
slug: 'pizza-react-native-mobile',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
scheme: 'pizzaapp',
userInterfaceStyle: 'light',That makes pizzaapp://order/abc123 open the app on the order screen. Expo Router maps
the path onto the same route tree the app already uses, so there is nothing else to declare — the
folder structure is the link structure.
⚠️ A custom scheme only works if the app is installed. Tap
pizzaapp://… without it and the browser reports an unknown protocol. That is fine for
returning from a payment flow you started, and useless for a link you email to someone who might not
have the app.
Universal and app links
The grown-up version: https://lovemesomecoding.com/order/abc123 opens the app when
it is installed and the website when it is not.
It costs more than a scheme because the OS verifies you own the domain. You serve a JSON file —
apple-app-site-association for iOS, assetlinks.json for Android — over
HTTPS from a fixed path, listing your app's identifier. The OS fetches it at install time. Expo
generates the native manifest entries from associatedDomains and
intentFilters in the config.
The verification step is where people get stuck, and the symptom is unhelpful: the link just opens the website, with no error anywhere. Both platforms have a validator; use it before assuming your code is wrong.
Coming back from a redirect
The most common reason to need any of this is not marketing. It is a third party sending the user back:
urlScheme="pizzaapp"That is the Stripe provider in the demo app. When a card needs 3D Secure, the customer leaves for
their bank's page and this scheme is what brings them back. Get it wrong — or forget to match it to
scheme in the config — and the app is simply never reopened, leaving a paid order the
customer never sees confirmed. Lesson 19 covers the flow.
The URL as state
The habit worth keeping from the web, and it is not obvious that it applies here:
const params = useLocalSearchParams<{ type?: string }>();
const router = useRouter();
const activeFilter: Filter = isFilter(params.type) ? params.type : 'ALL';The menu's active filter lives in the route's query params rather than in component state. On a
phone that still earns its keep for two reasons. A deep link —
pizzaapp://menu?type=PIZZA — opens on the right tab already filtered. And the filter
survives the screen being dropped from memory and rebuilt when the user returns to it, which a
useState would not.
Note isFilter: a type guard rather than a cast. Params arrive as
string | undefined, and pretending otherwise means a malformed link produces a screen in
an impossible state.
router.replace(filter === 'ALL' ? '/menu' : `/menu?type=${filter}`);replace, not push — a filter change is not a place to go back to. Get
this backwards and the back gesture walks through every filter the user tried.
What does not belong in the URL
Anything transient or private. Which modal is open, a half-typed form, an auth token. The test is whether you would be happy for someone to send that link to a friend.
Handling a link the app was not expecting
export default function NotFoundRoute() {
const router = useRouter();+not-found.tsx is Expo Router's catch-all, and it matters more here than on the web.
A website's old URLs are under your control; a deep link is out in the world in emails and
notifications, and it can point at a screen a newer build removed. Without a catch-all the app opens
to nothing and the user assumes it is broken.
Testing links without publishing anything
npx uri-scheme open "pizzaapp://order/abc123" --ios
xcrun simctl openurl booted "pizzaapp://menu?type=PIZZA"
adb shell am start -W -a android.intent.action.VIEW -d "pizzaapp://menu"Worth running early. Deep linking is the kind of feature that is configured once, assumed to work, and discovered broken by a customer.
What is next
Structuring a Real App — where all this code goes once there is more than a screen's worth.