One evening I added an "Open in LINE" button to an app. On Android it worked as intended: if the app was installed it opened, otherwise it sent people to the store. On iOS, in the simulator and on a real device, Linking.canOpenURL returned false every time, so everyone landed on the store page. LINE was definitely installed.
I spent about an hour suspecting my code. The cause was outside the code: I had never declared the scheme I wanted to check in Info.plist.
The thing I'd like to say first is that canOpenURL returning false and openURL failing are two different problems. Keeping them apart makes the diagnosis much shorter.
Two causes cover almost every case
Apple's documentation says that for apps linked on or after iOS 9.0, any scheme you pass to canOpenURL must be listed under LSApplicationQueriesSchemes in Info.plist. An undeclared scheme always returns false, even when a matching app is installed.
The other cause is simply that no app on the device handles the scheme. That also returns false, and from the outside the two look identical.
Here is the order I check things in:
| Step | What to check | What it tells you |
|---|---|---|
| 1 | Is the scheme in Info.plist? | If not, it is a missing declaration |
| 2 | Spelling, and no "://" in the entry | Only the scheme name goes in the plist |
| 3 | Did you re-run prebuild? | Editing app.json alone may not reach the native project |
| 4 | Are you testing in Expo Go? | Expo Go uses its own Info.plist, so your declaration has no effect |
Step 4 is where I got stuck first. As long as you test in Expo Go, nothing you put in app.json changes the result. You need a development build.
The app.json setup in Expo
Expo's guide shows LSApplicationQueriesSchemes under ios.infoPlist:
{
"expo": {
"ios": {
"infoPlist": {
"LSApplicationQueriesSchemes": ["line", "twitter"]
}
}
}
}This tells the OS which schemes you intend to query. It is line, not line://, because only the scheme name belongs in the plist.
In app code, I keep the check and the navigation separate so each is easy to change later:
import { Linking } from 'react-native';
async function openOrFallback(appUrl: string, storeUrl: string) {
try {
const installed = await Linking.canOpenURL(appUrl);
await Linking.openURL(installed ? appUrl : storeUrl);
} catch (e) {
// On Android the check itself can reject
await Linking.openURL(storeUrl);
}
}The try/catch is there because Expo's docs note that the promise rejects on Android when the check can't be made. I'd rather send people to the store than leave the screen stuck.
Android 11 and later: a different place to declare
Android has no Info.plist. Package visibility is handled by queries in AndroidManifest. Expo's guide adds manifest.queries through a config plugin rather than app.json, so copying the iOS habit of editing app.json won't change anything on Android.
iOS 27 lowers the limit to 25 entries
This is the part I most wanted to write down. According to Apple's documentation, apps linked on or after iOS 15 can list up to 50 entries in LSApplicationQueriesSchemes, and apps linked on or after iOS 27 are limited to 25.
If your app checks dozens of share targets, rebuilding with Xcode 27 could leave the later entries without effect. I'd handle it in three ways:
- Keep only the schemes you truly need to check (for share targets, a short list of the most common ones).
- Where you don't need to check, skip
canOpenURLand callopenURLdirectly — it isn't bound by the declaration. - Move toward universal links where you can, since the browser opens when no app is installed.
In my own wallpaper apps, I'm going through each place that checks before branching and keeping only the ones that matter.
A note on review
Listing a long run of schemes just to probe for other apps may prompt questions about purpose during review. I'd keep the list to links your screens genuinely use. If a build stalls in review, this checklist for blank-screen rejections may help.
When you touch native iOS configuration, prebuild behavior matters too. If you hit AppDelegate errors, see this note on the prebuild AppDelegate error. For the Android side, auditing dependency manifests is a useful companion.
Check as little as possible, and navigate with openURL. I try to hold to that line even as the list of schemes grows.
One next step: count how many schemes in your app.json you actually pass to canOpenURL. If the number is 25 or fewer, there's nothing to rush.