AppTech Logo
The Complete Guide to Setting Up Universal Links in iOS Expo Apps
react-native

The Complete Guide to Setting Up Universal Links in iOS Expo Apps

AS
Ahmer Saud
Lead Developer
July 03, 2026

The Problem: Why Deep Links Even Matter

Let's say you're building FoodHub — a food delivery app. Here's what happens without deep links:

Scenario: Your user finds a great pizza restaurant on Twitter

Without deep links:

  1. Friend texts: "Try Mario's Pizza, it's amazing" + app link
  2. You tap the link → goes to the App Store listing
  3. You install FoodHub
  4. App opens → you're at the home screen
  5. You search for "Mario's" manually
  6. 90% of users drop off here and never search

With deep links:

  1. Friend texts: "Try Mario's Pizza" + deep link
  2. You tap the link → app opens directly to Mario's Pizza page
  3. You see the menu, reviews, ratings
  4. You order

That's the entire difference. Deep links eliminate friction in the path from "someone shared this with me" → "I'm inside the thing they wanted me to see."

This matters because:

  • Higher engagement — users land exactly where you want them
  • Better sharing — people are more likely to share app content if the link works smoothly
  • App Store Optimization — you can create different app store listing pages for different content (like Custom Product Pages), and each one can deep link to the relevant part of your app
  • Marketing campaigns — ads can point directly to specific screens, not just the home screen

The Confusion: "Deep Link" Means Two Things

People use "deep link" in two conflicting ways, and this confusion kills half the setups out there:

  1. The umbrella term: Any link that takes you to a specific screen inside an app, not just the home screen. Both mechanisms below are "deep links."
  2. The specific mechanism: A link using a custom made-up scheme, like foodhub://restaurant/marios-pizza. Old, clunky, only works if the app is installed.

Universal Links are the modern answer — they're regular https:// URLs that the OS intelligently routes to your app if it's installed, or to Safari if it's not.

Why Universal Links (Not Custom Schemes)

Let's use FoodHub as the example. You want to share Mario's Pizza restaurant:

Option 1: Custom Scheme foodhub://restaurant/marios-pizza

  • ✅ Works instantly if user already has FoodHub
  • ❌ Does nothing if they don't have it (dead link)
  • ❌ Not clickable in most places (Instagram, Twitter, email clients don't recognize the scheme)
  • ❌ Can't share as a normal link — looks broken

Option 2: Universal Link https://foodhub.com/restaurant/marios-pizza

  • ✅ If they have FoodHub → opens app directly to Mario's page
  • ✅ If they don't → opens website showing "Get the app" button
  • ✅ Works everywhere (Twitter, WhatsApp, email, browsers)
  • ✅ Looks like a normal, professional link

Feature Custom Scheme Universal Link Works if app installed ✅ ✅ Works if app not installed ❌ (dead link) ✅ (shows website) Clickable in Twitter, Instagram, email ⚠️ Unreliable ✅ Always Can share as normal link ❌ ✅ Requires server setup ❌ ✅ App Store Connect support ❌ ✅

Conclusion: Custom schemes are useful for local dev testing. For production, especially Custom Product Pages or marketing campaigns, Universal Links are mandatory.

Complete Setup Process

Step 1: Apple Developer Account (One-time)

You need a paid Apple Developer account ($99/year). Universal Links require Team ID verification, which is not available on free accounts.

1.1 — Get your Team ID

  1. Log into developer.apple.com/account
  2. Navigate to Membership Details
  3. Copy your Team ID — it's a 10-character string like QQ57RJ5UTD

This is the only piece you need from Apple's side. The associated domains capability is registered automatically by EAS Build, so you don't need to manually enable it in App Store Connect.

Step 2: Your Website Configuration

Your website doesn't need to be a full web app — it just needs to serve one static verification file and optionally have a fallback page.

2.1 — Create the Apple App Site Association File

Create a file at exactly this path on your domain:

https://yourdomain.com/.well-known/apple-app-site-association

Important: No file extension. It must be in the /.well-known/ directory.

2.2 — File Content

Replace QQ57RJ5UTD with your actual Team ID and com.foodhub.app with your bundle identifier:

JSON
1{
2  "applinks": {
3    "apps": [],
4    "details": [
5      {
6        "appID": "QQ57RJ5UTD.com.foodhub.app",
7        "paths": ["/restaurant", "/restaurant/*", "/order", "/order/*"]
8      }
9    ]
10  }
11}
12

What each part means:

  • appID: Format is <TeamID>.<BundleIdentifier>. This ties the domain to your specific app.
  • paths: Which URL paths on your domain trigger the app. For FoodHub, we open the app for /restaurant/* (restaurant pages) and /order/* (past orders). Start with the paths you actually use — you can expand later.
  • apps: []: Leave this empty for Universal Links (it's for other link types).

2.3 — Hosting Requirements

All of these matter. Get one wrong and it silently fails with zero error messages:

  • HTTPS only — no HTTP fallbacks
  • No redirects — if yourdomain.com redirects to www.yourdomain.com, the file must be reachable at the final HTTPS URL
  • Content-Type header must be application/json — not text/plain, not text/html
  • Under 128KB (yours will be tiny, not a concern)

2.4 — Verify It's Reachable

From any machine with internet access:

curl -I https://foodhub.com/.well-known/apple-app-site-association

Expected response:

HTTP/2 200
content-type: application/json

Then fetch the actual content:

curl https://foodhub.com/.well-known/apple-app-site-association

Should return valid JSON, no HTML error page.

2.5 — Create a Fallback Page (Recommended)

When someone taps a Universal Link but doesn't have the app installed, they land on your website at that exact URL. For FoodHub, if someone taps a link to https://foodhub.com/restaurant/marios-pizza but doesn't have the app, they should see a "Get FoodHub" page.

At minimum, create pages at these paths on your website:

  • foodhub.com/restaurant/:id — shows restaurant info + "Get app" button
  • foodhub.com/order/:id — shows order details + "Get app" button

These can be simple landing pages. For example:

For the restaurant page:

HTML
1<!DOCTYPE html>
2<html>
3<head>
4  <title>Mario's Pizza - FoodHub</title>
5  <meta name="apple-itunes-app" content="app-id=1234567890, app-argument=https://foodhub.com/restaurant/marios-pizza">
6</head>
7<body>
8  <h1>Mario's Pizza</h1>
9  <p>Get the FoodHub app to see the menu and order</p>
10  <a href="https://apps.apple.com/app/id1234567890">Download on App Store</a>
11</body>
12</html>


The app-argument in the meta tag is Apple's native feature — if FoodHub is already installed, tapping the banner passes the URL directly to your app. If not installed, the "Download" button goes to the App Store.

Step 3: Expo App Configuration

All in your app.json (or app.config.ts).

3.1 — Add Associated Domains

JSON
1{
2  "expo": {
3    "ios": {
4      "bundleIdentifier": "com.foodhub.app",
5      "associatedDomains": [
6        "applinks:foodhub.com"
7      ]
8    }
9  }
10}
11

Critical: No https:// prefix, no trailing slash, no path — just the bare domain.

3.2 — Routing Setup (Expo Router)

If you're using Expo Router (file-based routing), nothing else needed — your /restaurant/:id route already handles https://foodhub.com/restaurant/marios-pizza automatically.

Read the incoming params in your route:

TSX
1// app/restaurant/[id].tsx
2import { useLocalSearchParams } from 'expo-router';
3
4export default function RestaurantScreen() {
5  const { id } = useLocalSearchParams<{ id?: string }>();
6  
7  // id will be the restaurant ID like 'marios-pizza'
8  console.log('Restaurant ID:', id);
9  
10  return (
11    <View>
12      <Text style={{ fontSize: 20, fontWeight: 'bold' }}>
13        Restaurant: {id}
14      </Text>
15      {/* Fetch and display the restaurant details */}
16    </View>
17  );
18}


Real-world example: When someone taps https://foodhub.com/restaurant/marios-pizza, Expo Router automatically routes to this screen with id='marios-pizza'. Your component fetches the restaurant details, shows the menu, and lets them order.

If you're on React Navigation (not Expo Router), you need a manual linking config:

TSX
1const linking = {
2  prefixes: ['https://foodhub.com'],
3  config: {
4    screens: {
5      restaurant: 'restaurant/:id',
6      order: 'order/:id',
7      home: '',
8    },
9  },
10};
11

Step 4: Building & Deployment

4.1 — You Need a Fresh Build

Associated Domains is a native entitlement baked into the compiled app binary, not JavaScript. It cannot be shipped via OTA/Expo Updates. Any time you add or change associatedDomains, rebuild:

Shell
1eas build --platform ios --profile development


EAS will automatically sync the capability to your Apple Developer account and generate a new provisioning profile that includes it.

4.2 — If You Get a Provisioning Profile Error

If the build fails with:

Provisioning profile doesn't support the Associated Domains capability
Provisioning profile doesn't include the com.apple.developer.associated-domains entitlement

Your old provisioning profile is stale. Fix it:

Shell
1eas credentials


Then:

  1. Select iOS
  2. Select your build profile
  3. Select Build Credentials
  4. Remove the provisioning profile (not the certificate, just the profile)
  5. Run eas build again — EAS will generate a fresh one

Step 5: Testing

This is where most people fail, usually by testing the wrong way.

5.1 — Install the Development Build

After eas build finishes, scan the QR code or open the install link on a real iPhone (not the simulator — Universal Links need a real device).

Once installed, run your dev server:

npx expo start --dev-client

5.2 — The Actual Test (The Part Everyone Gets Wrong)

❌ WRONG: Type or paste the URL into Safari's address bar.

✅ RIGHT: Tap the link from another app.

  1. Open the Notes app on your iPhone
  2. Type: https://foodhub.com/restaurant/marios-pizza (iOS auto-detects it as a link)
  3. Tap the link
  4. Expected: FoodHub app opens directly to Mario's Pizza restaurant page, no Safari

Repeat from Messages as a second test — slightly different code paths, good confirmation.

5.3 — Reading the Restaurant ID

Add a temporary on-screen debug display to confirm the param is actually there:

TSX
1export default function RestaurantScreen() {
2  const { id } = useLocalSearchParams<{ id?: string }>();
3  
4  return (
5    <View style={{ padding: 20 }}>
6      <Text style={{ fontSize: 20, fontWeight: 'bold' }}>
7        🍕 Restaurant ID: {id}
8      </Text>
9      {/* Your actual restaurant details below */}
10      <Text>Menu, reviews, ratings, order button...</Text>
11    </View>
12  );
13}


Test different restaurant links (/restaurant/marios-pizza, /restaurant/pizza-hut, etc.) and verify they show the correct ID.

5.4 — Test the Fallback Case

This is just as important — verify the "app not installed" path:

  1. Uninstall FoodHub from your device completely
  2. Tap https://foodhub.com/restaurant/marios-pizza from Notes/Messages
  3. Should land on your website fallback page (or auto-redirect to App Store)

Troubleshooting

The Link Opens Safari Instead of the App

Most likely causes, in order:

  1. You rebuilt after editing associatedDomains?
    • If you only did an OTA update, the entitlement never made it into the binary.
    • Rebuild: eas build --platform ios --profile development
  2. Testing in Safari's address bar?
    • Universal Links only activate on taps from other apps, never direct URL bar entry.
    • Test via Notes.app or Messages instead.
  3. The AASA file is unreachable or invalid.
    • Run the curl checks from Step 2.4
    • Validate JSON structure at jsonlint.com
    • Check content-type: application/json header
  4. The appID in the AASA file has a typo.
    • Format must be exactly: <10CharTeamID>.<bundleidentifier>
    • Example: QQ57RJ5UTD.com.foodhub.app — case-sensitive, no https://, no spaces
    • If unsure, verify with: npx expo config --type introspect | grep bundleIdentifier
  5. iOS is caching an old association.
    • Force-quit FoodHub completely (swipe up from app switcher)
    • Uninstall and reinstall FoodHub on the device
    • Give it a few minutes — iOS validates AASA through Apple's CDN, not your server directly
  6. You're on iOS < 18.
    • Custom Product Page deep links require iOS 18+.
    • On older devices, users see your default app entry point (home screen), not the deep linked content.
    • This is expected behavior, not a bug. Universal Links themselves work on iOS 10.3+, but CPP deep links specifically need iOS 18.

AASA File Validation Tools

Use these to verify your setup independently:

  • Google's Digital Asset Links validator (for Android, but useful sanity check):

https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://yourdomain.com

Integration with App Store Connect Custom Product Pages

Once Universal Links are working locally, Custom Product Pages are straightforward. For FoodHub, you might create one CPP for each restaurant cuisine type:

  1. Create a CPP in App Store Connect (e.g., "Pizza Restaurants on FoodHub")
  2. Customize the screenshots, description, etc. for that audience
  3. In the CPP editor, set the App Deep Link field:

https://foodhub.com/restaurant/marios-pizza

  1. Test the link before submitting for review (tap it from Notes.app)
  2. Submit the CPP for review (CPPs are reviewed separately from app versions)

Once approved:

  • When users tap "Open" on the CPP in the App Store, FoodHub opens directly to Mario's Pizza
  • No extra infrastructure needed — Universal Links handle the routing automatically
  • Users don't see the intermediate website (that only shows if they don't have the app)

Key Differences from Android (For Reference)

If you're also setting up Android, note these differences:

Aspect iOS (Universal Links) Android (App Links) Verification file apple-app-site-association (no extension) assetlinks.json Path /.well-known/apple-app-site-association /.well-known/assetlinks.json Signing requirement Team ID SHA-256 certificate fingerprint Fingerprints needed 1 per bundle ID 1+ (dev, prod, upload key if different) Config location ios.associatedDomains android.intentFilters Rebuild requirement Yes Yes

Android App Links are nearly identical in concept, just different implementation details.

Common Production Gotchas

1. Development vs. Production Builds

Your dev build uses EAS's shared signing certificate. Your production build uses a different one (from Google Play or your own keystore). If you only test the dev build and skip production testing, you might have silent failures in production.

Test both a dev build and (ideally) a production build before shipping Custom Product Pages live.

2. iOS 18+ Requirement for CPP Deep Links

Custom Product Page deep links only work on iOS 18 and above. On iOS 17 and below, users land at the default app entry point (usually the home screen), not the deep-linked content. This is an Apple limitation, not something you can work around.

3. The "Open in App" Preference

If a user ever taps the back-arrow in your app after opening via Universal Link, iOS remembers that preference and routes that specific link to Safari instead next time — even if the app is installed. Long-press the link and choose "Open in [App Name]" to reset it during testing if this happens.

References & Further Reading

Official Expo Documentation:

Apple Developer Documentation:

Testing & Debugging:

Related Deep Linking Concepts:

Checklist: Before You Ship

  • [ ] AASA file hosted at https://yourdomain.com/.well-known/apple-app-site-association
  • [ ] AASA content verified with curl and JSON validator
  • [ ] associatedDomains in app.json with exact domain (no https://, no trailing slash)
  • [ ] Fresh development build created after adding associatedDomains
  • [ ] Tested link tap from Notes.app (not Safari address bar)
  • [ ] Verified route params are received correctly via useLocalSearchParams()
  • [ ] Tested fallback page (uninstalled app, tapped link)
  • [ ] Tested multiple different deep links (different routes, different IDs)
  • [ ] Production build tested (if planning to ship to production soon)
  • [ ] Custom Product Page deep link field matches working Universal Link

What's Next

Once Universal Links are solid, your next steps depend on your needs:

  1. Set up the Android equivalent (App Links) — same concept, different file format and signing requirements
  2. Add deferred deep-linking (ChottuLink, Branch, etc.) if you need full "not installed → install → show right screen" automation across the install boundary
  3. Create Custom Product Pages in App Store Connect for different content categories, each with its own deep link
  4. Extend your deep-linking paths as you add more screens/routes to your app
  5. Build web fallback pages for each deep-link route, so non-installed users see relevant content

Questions & Gotchas

Q: Do I need a web app or website backend?
No. The AASA file is static JSON. Your website can be a simple landing page. Apple doesn't validate that your website is "real" — it only validates that you own the domain (via the AASA file) and that the domain is reachable over HTTPS.
Q: Will this work in Expo Go?
No. Universal Links are not supported in Expo Go. You need a development build or production build.
Q: Can I test without a real device?
Not really. The simulator's Universal Link support is incomplete and unreliable. Always test on a real iPhone.
Q: What if I have multiple subdomains (www.yourdomain.com, etc.)?
Add each separately to the paths array or create multiple entries in the AASA file. iOS treats yourdomain.com and www.yourdomain.com as different domains.