mobile-deep-linking-app-links
Deep linking patterns - Universal Links (iOS), App Links (Android), URI schemes, expo-linking API, React Navigation linking config, Expo Router automatic linking, AASA/assetlinks.json setup, deferred deep links, testing
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-deep-linking-app-links/skills/mobile-deep-linking-app-links
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Deep Linking & App Links Patterns
Quick Guide: Universal Links (iOS) and App Links (Android) are the gold standard -- they use HTTPS URLs that open your app directly or fall back to the website. Custom URI schemes (
myapp://) are simpler but less reliable (no fallback, can be hijacked). Useexpo-linkingfor URL handling (useURL,createURL,parse). Expo Router handles deep linking automatically. React Navigation requires alinkingconfig. Always test on real devices -- simulators miss edge cases.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use Universal Links (iOS) and App Links (Android) for production apps -- custom URI schemes have no fallback and can be hijacked by other apps)
(You MUST host AASA and assetlinks.json over HTTPS at /.well-known/ -- Apple and Google will reject HTTP or incorrectly hosted files)
(You MUST handle all three app states: cold start (app not running), background (app suspended), and foreground (app active) -- missing any state causes dropped links)
(You MUST test deep links on real devices -- simulators and emulators do not fully replicate OS-level link handling behavior)
(You MUST never pass sensitive data (tokens, passwords) in deep link URLs -- URLs are logged, cached, and visible in browser history)
</critical_requirements>
Auto-detection: deep link, deep linking, universal link, app link, URI scheme, custom scheme, expo-linking, Linking.useURL, Linking.createURL, Linking.parse, Linking.openURL, Linking.getInitialURL, linking config, apple-app-site-association, AASA, assetlinks.json, intentFilters, associatedDomains, deferred deep link, App Clip, Instant App, getInitialURL, addEventListener url
When to use:
- Setting up Universal Links (iOS) or App Links (Android) for HTTPS-based deep linking
- Configuring custom URI schemes for development or simple deep linking
- Handling incoming URLs across cold start, background, and foreground app states
- Configuring React Navigation linking config or using Expo Router automatic linking
- Hosting and validating AASA (iOS) or assetlinks.json (Android) verification files
- Implementing deferred deep links (link -> store -> install -> content)
- Testing deep links with CLI tools (
adb,xcrun simctl,uri-scheme)
Key patterns covered:
- Universal Links (iOS) and App Links (Android) end-to-end setup
- Custom URI scheme configuration and handling
- expo-linking API:
useURL,createURL,parse,getInitialURL - React Navigation
linkingconfig with path mapping, parameter parsing, nested navigators - Expo Router automatic deep linking (zero-config)
- AASA and assetlinks.json file format, hosting, and validation
- Handling incoming links in all app states
- Deferred deep linking concepts and implementation approaches
- Testing deep links with platform CLI tools
When NOT to use:
- Web-only routing without a native mobile app
- Push notification routing (handle in your notification skill, not deep linking)
- App-to-app communication via intents/activities (use your native modules skill)
Detailed Resources:
- examples/core.md - URI schemes, expo-linking API, handling incoming URLs, React Navigation linking config, Expo Router
- examples/verification-files.md - AASA file (iOS), assetlinks.json (Android), hosting requirements, validation
- examples/testing.md - Testing with adb, xcrun simctl, uri-scheme, debugging tips
- reference.md - API quick reference, linking config shape, testing commands
<decision_framework>
Decision Framework
Which Link Type to Use
Is the app already installed on the target device?
+-- Unknown/Maybe -> Use Universal Links / App Links (HTTPS)
| +-- Needs fallback to website? -> YES, this is why HTTPS links are preferred
| +-- Needs app store redirect? -> Implement deferred deep linking
+-- YES (guaranteed, e.g. internal tool) -> Custom URI scheme is acceptable
+-- NO (acquisition funnel) -> Deferred deep link via attribution service
Do you need the OS to open your app without a disambiguation dialog?
+-- YES -> Universal Links (iOS) / App Links (Android) with verified domains
+-- NO -> Custom URI scheme (shows "Open with..." on some devices)
Navigation Integration
Which router are you using?
+-- Expo Router -> Automatic. No configuration needed. File paths = deep links.
+-- React Navigation (static API) -> Add `linking` property per screen definition
+-- React Navigation (dynamic API) -> Pass `linking` prop to NavigationContainer
+-- Custom navigation -> Use expo-linking useURL hook + manual navigation logic
Link Type Comparison
| Feature | Custom URI Scheme | Universal Links (iOS) | App Links (Android) |
|---|---|---|---|
| Format | myapp://path |
https://domain/path |
https://domain/path |
| Fallback | None (fails silently) | Opens website | Opens website |
| Verification | None | AASA file on server | assetlinks.json on server |
| Hijack risk | Any app can register | OS-verified, secure | OS-verified, secure |
| Setup complexity | Low | Medium | Medium |
| Works without install | No | Yes (opens website) | Yes (opens website) |
| Disambiguation dialog | Sometimes | Never (verified) | Never (verified) |
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Using custom URI schemes in production without Universal Links / App Links -- no fallback when app is not installed, links fail silently
- Missing
autoVerify: trueon Android intent filters -- without it, App Links behave as regular deep links (disambiguation dialog shown) - Hosting AASA or assetlinks.json over HTTP instead of HTTPS -- Apple and Google reject non-HTTPS verification files
- Not handling cold start URLs --
useURLhandles this, but manual implementations that only useaddEventListenerwill miss the launch URL - Passing sensitive data (auth tokens, passwords, PII) in deep link URLs -- URLs are logged in analytics, cached by CDNs, visible in browser history
Medium Priority Issues:
- Not including
https://prefix in React Navigation linkingprefixesarray -- Universal Links/App Links will not be matched - Forgetting to rebuild after changing URI scheme or associated domains -- these are native-level changes that require a new build
- Not setting
initialRouteNamein nested navigator linking config -- back navigation will not work correctly from deep-linked screens - Hardcoding development tunnel URLs in production builds -- use environment-specific prefix arrays
Gotchas & Edge Cases:
- iOS caches AASA files for up to 24 hours -- changes to the file will not take effect immediately on devices that have already fetched it
- Universal Links do not work when typed directly into Safari's address bar -- they must be tapped from another app, Messages, Mail, or a webpage on a different domain
- Universal Links do not work when opened from the same domain -- a link on
example.compointing toexample.com/product/123will NOT open the app - Android App Links verification happens at install time -- if your server is down during install, verification fails and the link opens in the browser
Linking.parse()handles non-standard URL formats (like Expo Go URLs with--separators) -- use it instead ofnew URL()for consistency- Expo Go uses
exp://scheme with a different URL format (exp://127.0.0.1:8081/--/path) -- test with development builds for production-accurate behavior - Wildcard paths in AASA (
*) do not match/or.characters -- use multiple path entries if needed - Deep links received while the app is in the background may arrive with a delay on Android due to Doze mode and battery optimization
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST use Universal Links (iOS) and App Links (Android) for production apps -- custom URI schemes have no fallback and can be hijacked by other apps)
(You MUST host AASA and assetlinks.json over HTTPS at /.well-known/ -- Apple and Google will reject HTTP or incorrectly hosted files)
(You MUST handle all three app states: cold start (app not running), background (app suspended), and foreground (app active) -- missing any state causes dropped links)
(You MUST test deep links on real devices -- simulators and emulators do not fully replicate OS-level link handling behavior)
(You MUST never pass sensitive data (tokens, passwords) in deep link URLs -- URLs are logged, cached, and visible in browser history)
Failure to follow these rules will result in broken deep links, security vulnerabilities, and poor user experience when links fail silently.
</critical_reminders>
Files (skills)
-
examples
-
core.md 12.4 KB
# Deep Linking - Core Patterns > Core deep linking patterns: URI schemes, expo-linking API, handling incoming URLs, React Navigation linking config, Expo Router. See [SKILL.md](../SKILL.md) for decision guidance and red flags. --- ## Pattern 1: expo-linking API Essentials ### useURL Hook Handles both cold start URLs and foreground URL changes in a single hook. ```typescript import { useEffect } from "react"; import * as Linking from "expo-linking"; // useURL returns the initial URL on cold start AND subsequent URL changes export function DeepLinkHandler({ onNavigate, }: { onNavigate: (path: string, params: Record<string, string>) => void; }) { const url = Linking.useURL(); useEffect(() => { if (!url) return; const { path, queryParams } = Linking.parse(url); if (path) { onNavigate(path, queryParams as Record<string, string>); } }, [url, onNavigate]); return null; } ``` **Why good:** Single hook handles all app states (cold start, background resume, foreground), no need to manage `getInitialURL` + `addEventListener` separately ### createURL and parse ```typescript import * as Linking from "expo-linking"; // Create a deep link URL for your app const profileUrl = Linking.createURL("profile/123", { queryParams: { tab: "posts" }, }); // In dev: exp://127.0.0.1:8081/--/profile/123?tab=posts // In production: myapp://profile/123?tab=posts // Parse any URL into structured parts const parsed = Linking.parse("myapp://profile/123?tab=posts"); // { scheme: "myapp", hostname: null, path: "profile/123", queryParams: { tab: "posts" } } const parsedHttps = Linking.parse("https://example.com/product/456?ref=email"); // { scheme: "https", hostname: "example.com", path: "product/456", queryParams: { ref: "email" } } ``` **Why good:** `createURL` produces the correct format for dev (Expo Go `exp://`) and production (custom scheme), `parse` handles non-standard URL formats that `new URL()` would reject ### Manual Handling (Without useURL) Use when you need more control over the lifecycle, such as integrating with a custom navigation solution. ```typescript import { useEffect, useRef } from "react"; import * as Linking from "expo-linking"; export function useDeepLinkListener(onLink: (url: string) => void) { const hasHandledInitial = useRef(false); useEffect(() => { // Handle cold start URL (app was not running) async function handleInitialURL() { if (hasHandledInitial.current) return; hasHandledInitial.current = true; const initialUrl = await Linking.getInitialURL(); if (initialUrl) { onLink(initialUrl); } } handleInitialURL(); // Handle URLs received while app is running (foreground/background) const subscription = Linking.addEventListener("url", ({ url }) => { onLink(url); }); return () => { subscription.remove(); }; }, [onLink]); } ``` **Why good:** Explicit control over cold start vs foreground handling, ref prevents double-handling of initial URL on re-renders --- ## Pattern 2: React Navigation Linking Config ### Basic Setup ```typescript import * as Linking from "expo-linking"; import type { LinkingOptions } from "@react-navigation/native"; type RootStackParamList = { Home: undefined; Profile: { id: string }; Product: { slug: string }; Settings: { section?: string }; NotFound: undefined; }; const linking: LinkingOptions<RootStackParamList> = { prefixes: [ Linking.createURL("/"), // Custom scheme (myapp://) "https://example.com", // Universal Links / App Links ], config: { screens: { Home: "", // Matches root path Profile: "user/:id", // Matches /user/123 Product: { path: "product/:slug", parse: { slug: (slug: string) => slug.toLowerCase(), }, }, Settings: "settings/:section?", // Optional param NotFound: "*", // Catch-all for unmatched URLs }, }, }; // Pass to NavigationContainer <NavigationContainer linking={linking} fallback={<ActivityIndicator />}> <Stack.Navigator> <Stack.Screen name="Home" component={HomeScreen} /> <Stack.Screen name="Profile" component={ProfileScreen} /> <Stack.Screen name="Product" component={ProductScreen} /> <Stack.Screen name="Settings" component={SettingsScreen} /> <Stack.Screen name="NotFound" component={NotFoundScreen} /> </Stack.Navigator> </NavigationContainer> ``` **Why good:** Type-safe linking config matches `RootStackParamList`, `parse` transforms params before they reach the screen, catch-all `*` prevents unhandled URLs ### Nested Navigator Config The config structure must mirror the navigator nesting. ```typescript const linking: LinkingOptions<RootStackParamList> = { prefixes: [Linking.createURL("/"), "https://example.com"], config: { screens: { HomeTabs: { screens: { Feed: "feed", Explore: "explore", }, }, Profile: { path: "user/:id", screens: { Posts: "posts", // Matches /user/123/posts Followers: "followers", // Matches /user/123/followers }, }, Auth: { screens: { Login: "login", Register: "register", }, // initialRouteName ensures back button from deep link // goes to Login instead of nowhere initialRouteName: "Login", }, }, }, }; ``` **Why good:** Mirrors navigator hierarchy, `initialRouteName` ensures sensible back navigation from deep-linked nested screens ### Static API Configuration (React Navigation 7+) With the static API, linking is configured per-screen instead of in a separate config object. ```typescript import { createStaticNavigation } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; const RootStack = createNativeStackNavigator({ screens: { Home: { screen: HomeScreen, linking: { path: "" }, }, Profile: { screen: ProfileScreen, linking: { path: "user/:id", parse: { id: (id: string) => id }, }, }, Product: { screen: ProductScreen, linking: { path: "product/:slug", parse: { slug: (slug: string) => slug.toLowerCase() }, }, }, }, }); const Navigation = createStaticNavigation(RootStack); // In your app root: export function App() { return ( <Navigation linking={{ enabled: "auto", // Auto-generates kebab-case paths from screen names prefixes: [Linking.createURL("/"), "https://example.com"], }} /> ); } ``` **Why good:** Co-locates linking config with screen definition, `enabled: "auto"` generates paths from PascalCase screen names (Profile -> /profile) --- ## Pattern 3: Expo Router Automatic Deep Linking Expo Router requires zero deep linking configuration. File paths are deep link paths. ``` app/ _layout.tsx -> Layout wrapper (not a route) index.tsx -> / profile/[id].tsx -> /profile/123 product/[slug].tsx -> /product/blue-shirt settings/index.tsx -> /settings settings/[section].tsx -> /settings/notifications [...missing].tsx -> Catch-all for unmatched routes ``` ### Handling Parameters in Expo Router ```typescript // app/profile/[id].tsx import { useLocalSearchParams } from "expo-router"; export default function ProfileScreen() { const { id } = useLocalSearchParams<{ id: string }>(); // id is extracted from the URL path: /profile/123 -> id = "123" return <ProfileView userId={id} />; } ``` ### Handling Query Parameters ```typescript // app/product/[slug].tsx import { useLocalSearchParams } from "expo-router"; export default function ProductScreen() { const { slug, ref } = useLocalSearchParams<{ slug: string; ref?: string }>(); // /product/blue-shirt?ref=email -> slug = "blue-shirt", ref = "email" return <ProductView slug={slug} referrer={ref} />; } ``` **Why good:** No linking config to maintain, adding a file automatically creates a deep link, TypeScript params from `useLocalSearchParams` --- ## Pattern 4: URL Validation and Sanitization Never trust incoming URLs. Validate paths and parameters before navigating. ```typescript const VALID_DEEP_LINK_PATHS = new Set([ "profile", "product", "settings", "order", ]); const MAX_PARAM_LENGTH = 256; interface DeepLinkResult { screen: string; params: Record<string, string>; } export function validateDeepLink(url: string): DeepLinkResult | null { const { path, queryParams } = Linking.parse(url); if (!path) return null; const segments = path.split("/").filter(Boolean); const rootPath = segments[0]; if (!rootPath || !VALID_DEEP_LINK_PATHS.has(rootPath)) { return null; // Unknown path -- navigate to home or show error } // Sanitize parameters const sanitizedParams: Record<string, string> = {}; for (const [key, value] of Object.entries(queryParams ?? {})) { if (typeof value === "string" && value.length <= MAX_PARAM_LENGTH) { sanitizedParams[key] = value.replace(/[<>]/g, ""); // Strip basic injection chars } } return { screen: rootPath, params: { ...sanitizedParams, id: segments[1] ?? "" }, }; } ``` **Why good:** Allowlist of valid paths prevents navigation to unexpected screens, parameter sanitization prevents injection, length limit prevents abuse --- ## Pattern 5: Custom getInitialURL and subscribe (Push Notifications) When deep links come from multiple sources (URL links + push notifications), customize `getInitialURL` and `subscribe`. ```typescript import * as Linking from "expo-linking"; import type { LinkingOptions } from "@react-navigation/native"; const linking: LinkingOptions<RootStackParamList> = { prefixes: [Linking.createURL("/"), "https://example.com"], async getInitialURL() { // Check if app was opened from a push notification const notificationUrl = await getNotificationDeepLink(); if (notificationUrl) return notificationUrl; // Fall back to standard deep link handling const url = await Linking.getInitialURL(); return url; }, subscribe(listener) { // Listen for standard deep links const linkingSubscription = Linking.addEventListener("url", ({ url }) => { listener(url); }); // Listen for push notification deep links const notificationSubscription = subscribeToNotificationLinks((url) => { listener(url); }); return () => { linkingSubscription.remove(); notificationSubscription.remove(); }; }, config: { screens: { Home: "", Profile: "user/:id", Order: "order/:orderId", }, }, }; ``` **Why good:** Handles multiple link sources (URL + push) in a unified way, cleanup functions prevent memory leaks, prioritizes notification links over standard deep links --- ## Pattern 6: Deferred Deep Link Handling Deferred deep links persist the intended destination through the app store install flow. After install, check for a pending deep link on first open. ```typescript import { useEffect, useRef } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; const DEFERRED_LINK_KEY = "deferred_deep_link"; const DEFERRED_LINK_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours interface DeferredLink { url: string; timestamp: number; } // Call on first app launch after install export function useDeferredDeepLink(onLink: (url: string) => void) { const hasChecked = useRef(false); useEffect(() => { if (hasChecked.current) return; hasChecked.current = true; async function checkDeferredLink() { try { const stored = await AsyncStorage.getItem(DEFERRED_LINK_KEY); if (!stored) return; const parsed: DeferredLink = JSON.parse(stored); const age = Date.now() - parsed.timestamp; // Only honor links less than 24 hours old if (age < DEFERRED_LINK_MAX_AGE_MS) { onLink(parsed.url); } // Clean up regardless of age await AsyncStorage.removeItem(DEFERRED_LINK_KEY); } catch { // Silently fail -- deferred link is best-effort } } checkDeferredLink(); }, [onLink]); } ``` **Why good:** Expiry prevents stale navigation, single-use (deleted after read), error handling prevents crashes from corrupt storage **Note:** For production deferred deep linking with install attribution, use a dedicated attribution SDK. The pattern above shows the client-side concept -- the server-side link persistence and device matching are handled by the attribution service. -
testing.md 7.2 KB
# Deep Linking - Testing > Testing deep links with CLI tools, debugging common issues, and validating verification files. See [SKILL.md](../SKILL.md) for decision guidance. See [examples/verification-files.md](verification-files.md) for AASA/assetlinks.json setup. --- ## Pattern 1: Testing with CLI Tools ### iOS Simulator (xcrun simctl) ```bash # Test custom URI scheme xcrun simctl openurl booted "myapp://profile/123" # Test Universal Links (HTTPS) xcrun simctl openurl booted "https://example.com/product/456" # Test with query parameters xcrun simctl openurl booted "myapp://settings?section=notifications" # Specify a device (when multiple simulators are running) xcrun simctl openurl 9F3E4A1B-2C5D-4E6F-8A7B-1C2D3E4F5A6B "myapp://profile/123" ``` **Limitation:** Universal Links on simulator are unreliable -- the OS may open Safari instead. Always validate Universal Links on a real device. ### Android Emulator/Device (adb) ```bash # Test custom URI scheme adb shell am start -W -a android.intent.action.VIEW \ -d "myapp://profile/123" \ com.example.myapp # Test App Links (HTTPS) adb shell am start -W -a android.intent.action.VIEW \ -d "https://example.com/product/456" \ com.example.myapp # Test with query parameters adb shell am start -W -a android.intent.action.VIEW \ -d "myapp://settings?section=notifications" \ com.example.myapp # Verify App Links status for your app adb shell pm get-app-links com.example.myapp # Reset App Links verification (forces re-verification on next install) adb shell pm set-app-links --package com.example.myapp 0 all ``` ### Expo uri-scheme Tool ```bash # Test on iOS npx uri-scheme open "myapp://profile/123" --ios # Test on Android npx uri-scheme open "myapp://profile/123" --android # List registered schemes npx uri-scheme list --ios npx uri-scheme list --android ``` **Why good:** `uri-scheme` is simpler than raw `xcrun`/`adb` commands and works in Expo-managed projects --- ## Pattern 2: Testing App States Deep links behave differently depending on the app state. Test all three: ### Cold Start (App Not Running) ```bash # 1. Force-quit the app # iOS: Swipe up from app switcher # Android: adb shell am force-stop com.example.myapp # 2. Open deep link (app launches from scratch) adb shell am start -W -a android.intent.action.VIEW \ -d "myapp://product/456" \ com.example.myapp ``` **What to verify:** App launches and navigates directly to the target screen. The initial URL is captured by `Linking.getInitialURL()` or `useURL`. ### Background (App Suspended) ```bash # 1. Open the app normally, then press Home button # 2. Open deep link (app resumes from background) adb shell am start -W -a android.intent.action.VIEW \ -d "myapp://product/456" \ com.example.myapp ``` **What to verify:** App comes to foreground and navigates to the target screen. The URL is captured by `addEventListener('url')` or `useURL`. ### Foreground (App Active) ```bash # 1. Keep the app in the foreground # 2. Open deep link from another terminal window adb shell am start -W -a android.intent.action.VIEW \ -d "myapp://product/456" \ com.example.myapp ``` **What to verify:** App stays in foreground and navigates to the target screen without a visible app restart. --- ## Pattern 3: Validating Verification Files ### iOS AASA Validation ```bash # Apple's CDN-cached version of your AASA file curl -s "https://app-site-association.cdn-apple.com/a/v1/yourdomain.com" | jq . # Direct fetch from your server curl -sI "https://yourdomain.com/.well-known/apple-app-site-association" # Verify: Content-Type is application/json, status is 200 (not redirect) # Check the file content curl -s "https://yourdomain.com/.well-known/apple-app-site-association" | jq . ``` **Key checks:** - Content-Type header is `application/json` - No redirects (must be a direct 200 response) - `appIDs` format is `<TEAM_ID>.<BUNDLE_ID>` - Paths match the URLs you want to handle ### Android assetlinks.json Validation ```bash # Google's verification endpoint curl -s "https://digitalassetlinks.googleapis.com/v1/statements:list?\ source.web.site=https://yourdomain.com&\ relation=delegate_permission/common.handle_all_urls" | jq . # Direct fetch from your server curl -s "https://yourdomain.com/.well-known/assetlinks.json" | jq . # Check App Links verification status on device adb shell pm get-app-links com.example.myapp # Look for: "com.example.myapp: verified" ``` **Key checks:** - Google's endpoint returns your app in the `statements` array - `package_name` matches your app's package name - `sha256_cert_fingerprints` includes both upload AND signing keys - Status shows `verified` (not `legacy_failure` or `none`) --- ## Pattern 4: Common Debugging Scenarios ### Universal Links Not Opening App (iOS) ``` Problem: Tapping HTTPS link opens Safari instead of the app. Checklist: 1. Is the link from a DIFFERENT domain? (Same-domain links always open in Safari) 2. Is the link tapped (not typed into Safari address bar)? (Typed URLs open in Safari) 3. Did you long-press and choose "Open in Safari" previously? (iOS remembers this choice) Fix: Long-press the link again and choose "Open in [App Name]" 4. Is the AASA file accessible? Check: curl https://yourdomain.com/.well-known/apple-app-site-association 5. Is associatedDomains configured WITHOUT https:// prefix? 6. Did you rebuild after adding associatedDomains? (Requires new native build) 7. Has the AASA cache expired? (Apple caches for up to 24 hours) ``` ### App Links Not Verified (Android) ``` Problem: adb shell pm get-app-links shows "none" or "legacy_failure". Checklist: 1. Is autoVerify: true set in intent filters? 2. Is assetlinks.json served over HTTPS with Content-Type: application/json? 3. Does the package_name match your android.package? 4. Are ALL signing key fingerprints included? (Both upload and Play Store signing keys) 5. Was the server accessible when the app was installed? (Verification happens at install time) Fix: Uninstall and reinstall the app 6. Try manual re-verification: adb shell pm set-app-links --package com.example.myapp 0 all adb shell pm verify-app-links --re-verify com.example.myapp ``` ### Cold Start Link Not Handled ``` Problem: Opening a deep link when the app is not running does not navigate to the correct screen. Checklist: 1. Are you calling Linking.getInitialURL() or using useURL()? 2. Is getInitialURL() called BEFORE the navigation container is ready? Fix: Delay navigation until the navigator is mounted 3. Is the initial URL being consumed before React Navigation processes it? Fix: Use React Navigation's linking config instead of manual handling 4. Is there a splash screen blocking the URL handling? Fix: Ensure URL processing happens after splash screen is dismissed ``` ### URL Parameters Missing or Wrong ``` Problem: Screen receives empty or incorrect params from deep link. Checklist: 1. Does the linking config path pattern match the URL structure? e.g., "user/:id" matches /user/123 but NOT /users/123 2. Are parse functions returning the correct types? e.g., parse: { id: Number } converts "123" to 123 3. Are query params being correctly extracted? Check: Linking.parse(url).queryParams 4. Is the URL properly encoded? Special characters need URL encoding. ``` -
verification-files.md 7.5 KB
# Deep Linking - Verification Files > AASA (iOS) and assetlinks.json (Android) setup, hosting requirements, and validation. See [SKILL.md](../SKILL.md) for decision guidance. See [examples/core.md](core.md) for app-side configuration. --- ## Pattern 1: Apple App Site Association (AASA) File ### File Location Host at: `https://yourdomain.com/.well-known/apple-app-site-association` In Expo Router projects, place at: `public/.well-known/apple-app-site-association` **No file extension.** The file is named `apple-app-site-association` without `.json`. ### Modern Format (iOS 13+) ```json { "applinks": { "details": [ { "appIDs": ["ABCDE12345.com.example.myapp"], "components": [ { "/": "/product/*", "comment": "Matches product pages" }, { "/": "/user/*", "comment": "Matches user profiles" }, { "/": "/order/*", "comment": "Matches order details" }, { "/": "/settings", "comment": "Matches settings page exactly" }, { "/": "/admin/*", "exclude": true, "comment": "Exclude admin paths from opening in app" } ] } ] }, "activitycontinuation": { "apps": ["ABCDE12345.com.example.myapp"] }, "webcredentials": { "apps": ["ABCDE12345.com.example.myapp"] } } ``` **Format notes:** - `appIDs` value format: `<APPLE_TEAM_ID>.<BUNDLE_ID>` - Find your Team ID in Apple Developer portal under Membership - `components` array uses pattern matching (iOS 13+, preferred) - `exclude: true` prevents specific paths from opening the app - `activitycontinuation` enables Handoff between devices - `webcredentials` enables password autofill ### Legacy Format (iOS 12 and earlier) ```json { "applinks": { "apps": [], "details": [ { "appID": "ABCDE12345.com.example.myapp", "paths": ["/product/*", "/user/*", "/order/*", "NOT /admin/*"] } ] } } ``` **Note:** `"apps": []` must be an empty array in the legacy format. Use `NOT` prefix to exclude paths. ### Wildcard Rules | Pattern | Matches | Does NOT Match | | -------------------- | ----------------------- | ---------------------------------------------------- | | `/product/*` | `/product/123` | `/product/123/reviews` (single level only in legacy) | | `/product/*/reviews` | `/product/123/reviews` | `/product/123` | | `/user/?????` | `/user/alice` (5 chars) | `/user/bob` (3 chars) | **iOS 13+ components format:** The `*` wildcard in components matches across path separators, unlike the legacy format. ### App Config (Expo) ```json { "expo": { "ios": { "associatedDomains": ["applinks:example.com"] } } } ``` **Critical:** Do NOT include `https://` in the domain. Write `applinks:example.com`, not `applinks:https://example.com`. --- ## Pattern 2: Android Digital Asset Links (assetlinks.json) ### File Location Host at: `https://yourdomain.com/.well-known/assetlinks.json` In Expo Router projects, place at: `public/.well-known/assetlinks.json` ### File Structure ```json [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.example.myapp", "sha256_cert_fingerprints": [ "14:6D:E9:83:C5:7F:D8:4A:B4:2F:5E:E0:8F:3A:D6:F4:CA:41:1A:CF:45:BF:8D:10:76:76:CD:B1:55:AB:21:3E" ] } } ] ``` **Key fields:** - `package_name`: Must match `android.package` in your app config - `sha256_cert_fingerprints`: Array of SHA-256 fingerprints for your signing certificate(s) ### Getting SHA-256 Fingerprints **Via EAS Build:** ```bash eas credentials -p android # Select your build profile # Look for "SHA256 Fingerprint" in the output ``` **Via Google Play Console:** Navigate to: Release > Setup > App Signing > Digital Asset Links JSON **Via local keystore:** ```bash keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android # Look for SHA256 fingerprint in the output ``` **Important:** Include fingerprints for BOTH your upload key AND the Google Play signing key. If you only include the upload key, App Links will fail for Play Store builds (Google re-signs your app). ### Multiple Fingerprints (Development + Production) ```json [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.example.myapp", "sha256_cert_fingerprints": ["AA:BB:CC:...", "DD:EE:FF:..."] } } ] ``` ### App Config (Expo) ```json { "expo": { "android": { "intentFilters": [ { "action": "VIEW", "autoVerify": true, "data": [ { "scheme": "https", "host": "example.com", "pathPrefix": "/product" }, { "scheme": "https", "host": "example.com", "pathPrefix": "/user" } ], "category": ["BROWSABLE", "DEFAULT"] } ] } } } ``` **Critical:** `autoVerify: true` is required. Without it, Android treats these as regular deep links and shows a disambiguation dialog. --- ## Pattern 3: Hosting Requirements ### Both Platforms | Requirement | Detail | | ------------- | -------------------------------------------------------- | | Protocol | HTTPS only (no HTTP, no redirects from HTTP) | | Content-Type | `application/json` | | Accessibility | Publicly accessible (no authentication, no geo-blocking) | | Response code | 200 (not 301, 302, or any redirect) | | File size | < 128KB (Apple limit) | | CDN caching | Be aware of cache TTL when updating files | ### iOS-Specific - Apple's CDN fetches and caches AASA files -- changes can take up to 24 hours to propagate - The file is fetched when the app is installed, not when a link is tapped - Use Apple's AASA validator: `https://app-site-association.cdn-apple.com/a/v1/yourdomain.com` ### Android-Specific - Verification happens at app install time - If your server is down during install, verification fails (links open in browser) - Use Google's Digital Asset Links validator: `https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://yourdomain.com&relation=delegate_permission/common.handle_all_urls` --- ## Pattern 4: Combined Setup Checklist ``` iOS Universal Links: [ ] AASA file at /.well-known/apple-app-site-association (no .json extension) [ ] Served over HTTPS with Content-Type: application/json [ ] appIDs format: <TEAM_ID>.<BUNDLE_ID> [ ] associatedDomains in app.config.js (without https://) [ ] New native build after config change [ ] Tested on real device (not just simulator) Android App Links: [ ] assetlinks.json at /.well-known/assetlinks.json [ ] Served over HTTPS with Content-Type: application/json [ ] package_name matches android.package in app config [ ] SHA-256 fingerprints include BOTH upload key AND Play Store signing key [ ] autoVerify: true in intent filters [ ] intentFilters in app.config.js with correct host and pathPrefix [ ] New native build after config change [ ] Tested on real device (not just emulator) ```
-
-
reference.md 3 KB
# Deep Linking & App Links Reference > Quick-lookup tables and API reference. See [SKILL.md](SKILL.md) for decision frameworks, red flags, and anti-patterns. See [examples/testing.md](examples/testing.md) for debugging scenarios. --- ## expo-linking API Quick Reference | Method | Returns | Purpose | | ----------------------------- | ------------------------- | ------------------------------------------- | | `useURL()` | `string \| null` | Hook: initial URL + subsequent URL changes | | `getInitialURL()` | `Promise<string \| null>` | Cold start URL (one-time) | | `addEventListener('url', cb)` | `Subscription` | Listen for URL changes while app is running | | `createURL(path, opts?)` | `string` | Build a deep link URL for your app | | `parse(url)` | `ParsedURL` | Extract scheme, hostname, path, queryParams | | `openURL(url)` | `Promise<true>` | Open a URL in the appropriate app | | `canOpenURL(url)` | `Promise<boolean>` | Check if a URL can be handled | ### ParsedURL Shape ```typescript interface ParsedURL { scheme: string | null; hostname: string | null; path: string | null; queryParams: Record<string, string>; } ``` --- ## React Navigation Linking Config Shape ```typescript interface LinkingOptions<ParamList> { prefixes: string[]; config?: { screens: { [ScreenName: string]: | string | { path: string; exact?: boolean; parse?: Record<string, (value: string) => unknown>; stringify?: Record<string, (value: unknown) => string>; screens?: { /* nested screens */ }; initialRouteName?: string; alias?: string[]; }; }; }; getInitialURL?: () => Promise<string | null>; subscribe?: (listener: (url: string) => void) => () => void; getStateFromPath?: (path: string, options?: object) => object; getPathFromState?: (state: object, options?: object) => string; filter?: (url: string) => boolean; } ``` --- ## Testing Commands Quick Reference ```bash # iOS Simulator xcrun simctl openurl booted "myapp://profile/123" xcrun simctl openurl booted "https://example.com/product/456" # Android adb shell am start -W -a android.intent.action.VIEW -d "myapp://profile/123" com.example.myapp adb shell pm get-app-links com.example.myapp adb shell pm verify-app-links --re-verify com.example.myapp # Expo uri-scheme npx uri-scheme open "myapp://profile/123" --ios npx uri-scheme open "myapp://profile/123" --android npx uri-scheme list --ios # Validate server files curl -s "https://app-site-association.cdn-apple.com/a/v1/yourdomain.com" | jq . curl -s "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://yourdomain.com&relation=delegate_permission/common.handle_all_urls" | jq . ``` -
SKILL.md 16 KB
--- name: mobile-deep-linking-app-links description: Deep linking patterns - Universal Links (iOS), App Links (Android), URI schemes, expo-linking API, React Navigation linking config, Expo Router automatic linking, AASA/assetlinks.json setup, deferred deep links, testing --- # Deep Linking & App Links Patterns > **Quick Guide:** Universal Links (iOS) and App Links (Android) are the gold standard -- they use HTTPS URLs that open your app directly or fall back to the website. Custom URI schemes (`myapp://`) are simpler but less reliable (no fallback, can be hijacked). Use `expo-linking` for URL handling (`useURL`, `createURL`, `parse`). Expo Router handles deep linking automatically. React Navigation requires a `linking` config. Always test on real devices -- simulators miss edge cases. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST use Universal Links (iOS) and App Links (Android) for production apps -- custom URI schemes have no fallback and can be hijacked by other apps)** **(You MUST host AASA and assetlinks.json over HTTPS at `/.well-known/` -- Apple and Google will reject HTTP or incorrectly hosted files)** **(You MUST handle all three app states: cold start (app not running), background (app suspended), and foreground (app active) -- missing any state causes dropped links)** **(You MUST test deep links on real devices -- simulators and emulators do not fully replicate OS-level link handling behavior)** **(You MUST never pass sensitive data (tokens, passwords) in deep link URLs -- URLs are logged, cached, and visible in browser history)** </critical_requirements> --- **Auto-detection:** deep link, deep linking, universal link, app link, URI scheme, custom scheme, expo-linking, Linking.useURL, Linking.createURL, Linking.parse, Linking.openURL, Linking.getInitialURL, linking config, apple-app-site-association, AASA, assetlinks.json, intentFilters, associatedDomains, deferred deep link, App Clip, Instant App, getInitialURL, addEventListener url **When to use:** - Setting up Universal Links (iOS) or App Links (Android) for HTTPS-based deep linking - Configuring custom URI schemes for development or simple deep linking - Handling incoming URLs across cold start, background, and foreground app states - Configuring React Navigation linking config or using Expo Router automatic linking - Hosting and validating AASA (iOS) or assetlinks.json (Android) verification files - Implementing deferred deep links (link -> store -> install -> content) - Testing deep links with CLI tools (`adb`, `xcrun simctl`, `uri-scheme`) **Key patterns covered:** - Universal Links (iOS) and App Links (Android) end-to-end setup - Custom URI scheme configuration and handling - expo-linking API: `useURL`, `createURL`, `parse`, `getInitialURL` - React Navigation `linking` config with path mapping, parameter parsing, nested navigators - Expo Router automatic deep linking (zero-config) - AASA and assetlinks.json file format, hosting, and validation - Handling incoming links in all app states - Deferred deep linking concepts and implementation approaches - Testing deep links with platform CLI tools **When NOT to use:** - Web-only routing without a native mobile app - Push notification routing (handle in your notification skill, not deep linking) - App-to-app communication via intents/activities (use your native modules skill) **Detailed Resources:** - [examples/core.md](examples/core.md) - URI schemes, expo-linking API, handling incoming URLs, React Navigation linking config, Expo Router - [examples/verification-files.md](examples/verification-files.md) - AASA file (iOS), assetlinks.json (Android), hosting requirements, validation - [examples/testing.md](examples/testing.md) - Testing with adb, xcrun simctl, uri-scheme, debugging tips - [reference.md](reference.md) - API quick reference, linking config shape, testing commands --- <philosophy> ## Philosophy Deep linking connects the outside world to specific screens in your app. The goal is a seamless experience: user taps a link, your app opens to the right content. Three link types exist, each with different trade-offs: 1. **Universal Links (iOS) / App Links (Android)** -- HTTPS URLs verified by the OS. App opens directly without disambiguation dialog. Falls back to website if app not installed. **Use these for production.** 2. **Custom URI schemes** (`myapp://path`) -- Simple to set up but unreliable: no fallback if app not installed, any app can register the same scheme (hijacking risk), and some platforms block them. **Use for development or internal tools only.** 3. **Deferred deep links** -- User clicks link, gets sent to app store, installs app, then lands on the intended content. Requires a third-party service or custom server-side logic. **Use when acquisition funnels matter.** **Key architectural principle:** The link handler is the entry point to your navigation. It must work in all three app states (cold start, background, foreground) and gracefully handle invalid or expired links. Never trust link parameters -- validate and sanitize them before navigating. **Expo Router vs React Navigation:** - **Expo Router** handles deep linking automatically -- every file-based route is a deep link with zero configuration - **React Navigation** requires a `linking` config object that maps URL paths to screen names </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Custom URI Scheme Setup Configure a custom scheme in your app config so links like `myapp://profile/123` open your app. ```json { "expo": { "scheme": "myapp" } } ``` After adding a scheme, rebuild the app -- scheme changes require a new native build. **Why good:** Simple to configure, works immediately in development, no server-side setup needed **Gotcha:** Custom schemes have no fallback -- if the app is not installed, the link fails silently. Any app can register the same scheme, so there is no guarantee your app handles it. See [examples/core.md](examples/core.md) for handling incoming scheme URLs. --- ### Pattern 2: Universal Links (iOS) Setup Universal Links require a two-way association: your server hosts an AASA file declaring which paths belong to your app, and your app declares the associated domain. ```json { "expo": { "ios": { "associatedDomains": ["applinks:example.com"] } } } ``` **Critical:** Omit the `https://` protocol from the domain value. The AASA file must be served over HTTPS at `https://example.com/.well-known/apple-app-site-association`. See [examples/verification-files.md](examples/verification-files.md) for the complete AASA file format and hosting requirements. --- ### Pattern 3: App Links (Android) Setup App Links use intent filters with `autoVerify: true` and an assetlinks.json file on your server. ```json { "expo": { "android": { "intentFilters": [ { "action": "VIEW", "autoVerify": true, "data": [ { "scheme": "https", "host": "example.com", "pathPrefix": "/product" } ], "category": ["BROWSABLE", "DEFAULT"] } ] } } } ``` **Critical:** `autoVerify: true` is required -- without it, Android treats these as regular deep links (shows disambiguation dialog instead of opening directly). See [examples/verification-files.md](examples/verification-files.md) for the assetlinks.json file format and SHA-256 fingerprint retrieval. --- ### Pattern 4: Handling Incoming URLs with expo-linking Use the `useURL` hook to handle URLs in all app states (cold start, background, foreground). Parse URLs with `Linking.parse()` to extract path and query parameters. ```typescript import * as Linking from "expo-linking"; export function DeepLinkHandler() { const url = Linking.useURL(); useEffect(() => { if (url) { const { hostname, path, queryParams } = Linking.parse(url); // Navigate based on parsed URL } }, [url]); return null; } ``` **Why good:** `useURL` handles both the initial launch URL and subsequent foreground URLs -- no need to manage `getInitialURL` and `addEventListener` separately. See [examples/core.md](examples/core.md) for `createURL`, `parse`, and complete handling patterns. --- ### Pattern 5: React Navigation Linking Config Map URL paths to screens using the `linking` config. Paths support parameters, optional segments, regex patterns, and nested navigators. ```typescript import * as Linking from "expo-linking"; const linking = { prefixes: [Linking.createURL("/"), "https://example.com", "myapp://"], config: { screens: { Home: "", Profile: "user/:id", Product: { path: "product/:slug", parse: { slug: (slug: string) => slug.toLowerCase() }, }, NotFound: "*", }, }, }; ``` **Why good:** Declarative path-to-screen mapping, parameter parsing built in, wildcard catch-all for unmatched URLs See [examples/core.md](examples/core.md) for nested navigator config, parameter parsing, and static API setup. --- ### Pattern 6: Expo Router Automatic Deep Linking With Expo Router, every file in the `app/` directory is automatically a deep linkable route. No linking config needed. ``` app/ index.tsx -> / profile/[id].tsx -> /profile/123 product/[slug].tsx -> /product/blue-shirt settings.tsx -> /settings ``` **Why good:** Zero configuration, file paths ARE the deep link paths, adding a screen automatically creates a deep link **When to use:** Expo Router projects. If using React Navigation directly, use Pattern 5 instead. --- ### Pattern 7: Deferred Deep Links Deferred deep links work when the app is not yet installed: user taps link, goes to app store, installs, then opens to the intended content. This requires server-side logic to persist the link destination through the install flow. **Implementation approaches:** - **Attribution SDKs** -- Third-party services handle the full deferred linking flow with install attribution - **OS-level referrer** -- Android provides an install referrer API that can carry a URL through the Play Store install. iOS has no equivalent (clipboard-based heuristics exist but require paste permission) - **Custom server** -- Store the link destination server-side keyed by device fingerprint, retrieve after install **Key limitation:** Deferred deep links are inherently probabilistic on iOS. Android's install referrer provides deterministic matching. See [examples/core.md](examples/core.md) for deferred deep link handling patterns. </patterns> --- <decision_framework> ## Decision Framework ### Which Link Type to Use ``` Is the app already installed on the target device? +-- Unknown/Maybe -> Use Universal Links / App Links (HTTPS) | +-- Needs fallback to website? -> YES, this is why HTTPS links are preferred | +-- Needs app store redirect? -> Implement deferred deep linking +-- YES (guaranteed, e.g. internal tool) -> Custom URI scheme is acceptable +-- NO (acquisition funnel) -> Deferred deep link via attribution service Do you need the OS to open your app without a disambiguation dialog? +-- YES -> Universal Links (iOS) / App Links (Android) with verified domains +-- NO -> Custom URI scheme (shows "Open with..." on some devices) ``` ### Navigation Integration ``` Which router are you using? +-- Expo Router -> Automatic. No configuration needed. File paths = deep links. +-- React Navigation (static API) -> Add `linking` property per screen definition +-- React Navigation (dynamic API) -> Pass `linking` prop to NavigationContainer +-- Custom navigation -> Use expo-linking useURL hook + manual navigation logic ``` ### Link Type Comparison | Feature | Custom URI Scheme | Universal Links (iOS) | App Links (Android) | | --------------------- | --------------------- | --------------------- | ------------------------- | | Format | `myapp://path` | `https://domain/path` | `https://domain/path` | | Fallback | None (fails silently) | Opens website | Opens website | | Verification | None | AASA file on server | assetlinks.json on server | | Hijack risk | Any app can register | OS-verified, secure | OS-verified, secure | | Setup complexity | Low | Medium | Medium | | Works without install | No | Yes (opens website) | Yes (opens website) | | Disambiguation dialog | Sometimes | Never (verified) | Never (verified) | </decision_framework> --- <red_flags> ## RED FLAGS **High Priority Issues:** - Using custom URI schemes in production without Universal Links / App Links -- no fallback when app is not installed, links fail silently - Missing `autoVerify: true` on Android intent filters -- without it, App Links behave as regular deep links (disambiguation dialog shown) - Hosting AASA or assetlinks.json over HTTP instead of HTTPS -- Apple and Google reject non-HTTPS verification files - Not handling cold start URLs -- `useURL` handles this, but manual implementations that only use `addEventListener` will miss the launch URL - Passing sensitive data (auth tokens, passwords, PII) in deep link URLs -- URLs are logged in analytics, cached by CDNs, visible in browser history **Medium Priority Issues:** - Not including `https://` prefix in React Navigation linking `prefixes` array -- Universal Links/App Links will not be matched - Forgetting to rebuild after changing URI scheme or associated domains -- these are native-level changes that require a new build - Not setting `initialRouteName` in nested navigator linking config -- back navigation will not work correctly from deep-linked screens - Hardcoding development tunnel URLs in production builds -- use environment-specific prefix arrays **Gotchas & Edge Cases:** - iOS caches AASA files for up to 24 hours -- changes to the file will not take effect immediately on devices that have already fetched it - Universal Links do not work when typed directly into Safari's address bar -- they must be tapped from another app, Messages, Mail, or a webpage on a different domain - Universal Links do not work when opened from the same domain -- a link on `example.com` pointing to `example.com/product/123` will NOT open the app - Android App Links verification happens at install time -- if your server is down during install, verification fails and the link opens in the browser - `Linking.parse()` handles non-standard URL formats (like Expo Go URLs with `--` separators) -- use it instead of `new URL()` for consistency - Expo Go uses `exp://` scheme with a different URL format (`exp://127.0.0.1:8081/--/path`) -- test with development builds for production-accurate behavior - Wildcard paths in AASA (`*`) do not match `/` or `.` characters -- use multiple path entries if needed - Deep links received while the app is in the background may arrive with a delay on Android due to Doze mode and battery optimization </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST use Universal Links (iOS) and App Links (Android) for production apps -- custom URI schemes have no fallback and can be hijacked by other apps)** **(You MUST host AASA and assetlinks.json over HTTPS at `/.well-known/` -- Apple and Google will reject HTTP or incorrectly hosted files)** **(You MUST handle all three app states: cold start (app not running), background (app suspended), and foreground (app active) -- missing any state causes dropped links)** **(You MUST test deep links on real devices -- simulators and emulators do not fully replicate OS-level link handling behavior)** **(You MUST never pass sensitive data (tokens, passwords) in deep link URLs -- URLs are logged, cached, and visible in browser history)** **Failure to follow these rules will result in broken deep links, security vulnerabilities, and poor user experience when links fail silently.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.