Expo. EAS. A production app on the store. A staging app for QA. Deep links for reset-password, magic share, campaign landing. If both binaries claimed the same Universal Link, iOS picked one. It picked production. Staging "did not work." Production opened a path it did not know.
QA taps a staging email. The store app launches. Blank screen, or yesterday's production route. Or the reverse: a customer taps a marketing link and lands in an internal build that asks them to log into a fake catalog. That last one is how you get a one-star review that mentions "test."
I reused the production associated domain on the staging Expo profile because "it is the same path." Apple does not care that your EAS profile is named staging. The AASA file on that host lists app ids. If both bundle ids are there, or if only production is there, the wrong binary wins.
Custom schemes have the same bug. myapp://reset on both flavors means whichever app is installed handles it. On a device with both, you are playing dice.
Split the claim.
{
"expo": {
"scheme": "myapp-staging",
"ios": {
"bundleIdentifier": "com.example.app.staging",
"associatedDomains": ["applinks:staging.example.com"]
},
"android": {
"package": "com.example.app.staging",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "staging.example.com" }]
}
]
}
}
}Production keeps myapp, the store bundle id, and example.com. Staging emails and Klaviyo test flows must emit the staging host. AASA on each host lists only that host's app id.
I also stopped putting staging bundle ids in the production AASA "just in case." That is how a leftover QA install hijacks a customer link.
Two apps. Two hosts. Two schemes. Deep links are a namespace. Treat them like one.
Happy coding!
Sander