The morning after I added "follow us on TikTok" and "share on X" buttons to one of my wallpaper apps, three reviewers wrote to say that nothing happened when they tapped them on iPhone. Everything worked in the simulator and in my development build, so narrowing it down to store-distributed builds took half a day.
The cause was embarrassingly small: my Info.plist had no LSApplicationQueriesSchemes entries at all. I had spent those hours suspecting my code, when the answer was in a config file I had not opened.
Before asking whether an app can be opened, declare which apps you are allowed to ask about. That is the whole rule I took from it.
Recognize the symptoms first
Four observations together point strongly at this cause.
| What you see | What it suggests |
|---|---|
| Works in the simulator and development build; fails in TestFlight or App Store builds | The built `Info.plist` differs between builds |
| `canOpenURL("tiktok://...")` returns `false` (or rejects) | The scheme is undeclared |
| Xcode logs `This app is not allowed to query for scheme tiktok` | Close to conclusive |
| The same code works on Android | An iOS-specific rule, though Android 11+ has its own (see below) |
React Native's documentation states that as of iOS 9 you must provide LSApplicationQueriesSchemes in Info.plist, or canOpenURL() will always resolve to false. Apple introduced this as a privacy boundary so apps cannot probe which other apps are installed.
openURL itself often still works without a declaration. But if your code branches on canOpenURL to decide between the app and the web, an always-false answer sends everyone to Safari — even people who have TikTok installed.
Fix 1: Add LSApplicationQueriesSchemes in your app config
Rork follows Expo's app config, so you never touch Info.plist directly. In app.json (or app.config.ts):
{
"expo": {
"name": "MyApp",
"ios": {
"bundleIdentifier": "com.example.myapp",
"infoPlist": {
"LSApplicationQueriesSchemes": [
"tiktok",
"twitter",
"instagram",
"fb",
"fb-messenger",
"youtube",
"comgooglemaps",
"spotify",
"slack"
]
}
}
}
}With app.config.ts:
import type { ExpoConfig } from "expo/config";
const config: ExpoConfig = {
name: "MyApp",
slug: "myapp",
ios: {
bundleIdentifier: "com.example.myapp",
infoPlist: {
LSApplicationQueriesSchemes: [
"tiktok",
"twitter",
"instagram",
"fb",
"fb-messenger",
"youtube",
"comgooglemaps",
"spotify",
"slack",
],
},
},
};
export default config;Standard schemes such as http, https, mailto, tel, and sms opened fine for me without a declaration. I used to list them "just in case", but I now keep only what actually needs declaring, which makes later audits easier.
Fix 2: Use the scheme the app really registers
App names and schemes rarely match. For X, writing "x" did not behave as I expected; in my testing "twitter" was the one that worked. That could change on the app's side, so I'd suggest calling canOpenURL on a real device before each release. For LINE's add-friend flow, a Universal Link (https://line.me/R/ti/p/...) is safer than line://.
| You want to open | Scheme | Declare? |
|---|---|---|
| TikTok | `tiktok` | Yes |
| X (formerly Twitter) | `twitter` | Yes |
| `instagram` | Yes | |
| Facebook / Messenger | `fb` / `fb-messenger` | Yes |
| YouTube | `youtube` (or `vnd.youtube`) | Yes |
| Google Maps | `comgooglemaps` | Yes |
| Spotify / Slack | `spotify` / `slack` | Yes |
| LINE add-friend | Universal Link (`https`) | No (it is `https`) |
For anything else, the vendor's own developer documentation is the safest source. Third-party scheme lists go stale, so I confirm on a device before relying on them.
Fix 3: Rebuild — config changes are baked into the native build
Many people lose a second half-day here. Editing app.json does not affect an existing Expo Go session or Development Build, because infoPlist values are baked into the native binary:
# Rebuild the development client
eas build --profile development --platform ios
# Rebuild the production artifact
eas build --profile production --platform ios
# Or prebuild locally and install on a device
npx expo prebuild --clean
npx expo run:ios --deviceexpo prebuild --clean regenerates ios/, so back up anything custom there first.
Fix 4: Don't rely on canOpenURL alone
Even with the declaration in place, a missing fallback means the same symptom returns the day a scheme changes. According to React Native's documentation, canOpenURL can reject on iOS when a scheme is undeclared, and openURL rejects when no registered app can open the URL. So catch both failures and end on the web URL:
// utils/openExternalApp.ts
import { Linking } from "react-native";
type AppLink = {
/** Deep link or Universal Link to the native app */
appUrl: string;
/** Web fallback when the app cannot be opened */
webUrl: string;
};
export async function openExternalApp({ appUrl, webUrl }: AppLink): Promise<void> {
try {
if (await Linking.canOpenURL(appUrl)) {
await Linking.openURL(appUrl);
return;
}
} catch (e) {
// Undeclared scheme (canOpenURL rejects) or no app to open (openURL rejects)
if (__DEV__) {
console.warn("[openExternalApp] fallback to web", e);
}
}
await Linking.openURL(webUrl);
}
// Example
// await openExternalApp({
// appUrl: "tiktok://user?username=example",
// webUrl: "https://www.tiktok.com/@example",
// });The outer try/catch is the point. Keep in mind that this fallback can also hide a configuration gap — I'd always log a warning in development builds so a quiet drift to the web does not go unnoticed in production.
Fix 5: Call limits, and Android's separate rule
React Native's documentation says that for apps linked against an earlier iOS but running on iOS 9 or later, canOpenURL can be called up to 50 times; after that it always resolves to false, until the app is reinstalled or upgraded. I could not confirm Apple's own page on how many schemes you may declare, so I won't state a hard cap here. Still, calling canOpenURL every time a button renders is wasteful; checking once at startup and caching the result has been easier for me to reason about.
On the Android side, when you target Android 11 (SDK 30) or later, the same documentation says you must declare the intents in AndroidManifest.xml under <queries>. So "it works on Android" may not hold for every target SDK.
If canOpenURL still returns false
Walk through these in order.
- Wrong build. Check that the build number served by TestFlight matches your code. I once re-submitted an old binary after fixing
app.jsonand lost half a day. - EAS cache. Re-run
eas buildwith--clear-cache. EAS builds in the cloud, so clearing your local Xcode DerivedData will not help. - Spelling. Compare the declared string and the URL you call, including case and the
://part.
What to do next
Today, open your Rork project's app.json and check each external scheme your buttons can fire against LSApplicationQueriesSchemes, one by one. If even one is missing, your iOS users may have been quietly landing on the web all along.