# Universal Links

> Set up and test universal links for iOS using setup-safari

- Skill: `evanbacon/universal-links` (Agent Skill)
- Install (CLI): `npx skillmds@latest add evanbacon/universal-links`
- Raw SKILL.md: https://api.skillmd.com/api/skills/evanbacon/universal-links/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: evanbacon (https://skillmd.com/u/evanbacon)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/evanbacon/universal-links

---


Use `bunx setup-safari` to configure and test universal links (deep links) on iOS.

## Important: When Custom Builds Are Needed

**Universal links with custom domains require custom native builds.** Your app will no longer work in Expo Go.

However, for basic deep linking during development:

- **Expo Go supports** the `exp://` URL scheme and Expo-hosted URLs
- **Custom builds required** for your own domain's universal links (applinks:yourdomain.com)

If you only need deep linking for development/testing, try `npx expo start` with Expo Go first. Only create custom builds when you need production universal links with your own domain.

## Automated Setup (Recommended)

Run setup-safari with your Apple ID to automate credential lookup:

```bash
EXPO_APPLE_ID="your-apple-id@email.com" bunx setup-safari
```

This will:

1. Authenticate with Apple Developer Portal
2. Enable Associated Domains for your bundle ID
3. Output the AASA file content and meta tag

**After running, you must manually:**

### 1. Create the AASA file

Create `public/.well-known/apple-app-site-association` (no file extension):

```json
{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAM_ID.com.your.bundleid"],
        "components": [
          {
            "/": "*",
            "comment": "Matches all routes"
          }
        ]
      }
    ]
  },
  "activitycontinuation": {
    "apps": ["TEAM_ID.com.your.bundleid"]
  },
  "webcredentials": {
    "apps": ["TEAM_ID.com.your.bundleid"]
  }
}
```

### 2. Create src/app/+html.tsx with Smart App Banner

```tsx
import { ScrollViewStyleReset } from "expo-router/html";

export default function Root({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1, shrink-to-fit=no"
        />
        <meta name="apple-itunes-app" content="app-id=YOUR_ITUNES_ID" />
        <ScrollViewStyleReset />
      </head>
      <body>{children}</body>
    </html>
  );
}
```

### 3. Add Associated Domains to app.json

```json
{
  "expo": {
    "ios": {
      "associatedDomains": [
        "applinks:yourdomain.com",
        "activitycontinuation:yourdomain.com",
        "webcredentials:yourdomain.com"
      ]
    }
  }
}
```

### 4. Deploy and Rebuild

```bash
# Deploy web (AASA file must be accessible)
npx expo export -p web && npx eas-cli deploy

# Rebuild iOS app with new entitlements
npx expo run:ios
# Or for TestFlight: npx testflight
```

## Interactive Setup (Alternative)

For interactive mode (requires TTY):

```bash
bunx setup-safari
```

## How Universal Links Work

Universal links require two parts:

1. **AASA file on your server** - Tells iOS which paths your app handles
2. **Associated Domains entitlement** - Tells your app which domains to claim

## AASA File Format

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

```json
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAM_ID.com.example.app",
        "paths": ["*"]
      }
    ]
  }
}
```

Path patterns:

- `*` - Match all paths
- `/products/*` - Match paths starting with /products/
- `/item/???` - Match exactly 3 characters after /item/
- `NOT /admin/*` - Exclude paths

## Associated Domains Entitlement

In your `app.json` or `app.config.js`:

```json
{
  "expo": {
    "ios": {
      "associatedDomains": [
        "applinks:yourdomain.com",
        "applinks:www.yourdomain.com"
      ]
    }
  }
}
```

## Testing Universal Links

After setup, test with setup-safari:

```bash
npx setup-safari
```

Or manually test in Safari:

1. Open Safari on iOS device/simulator
2. Navigate to a URL that should open your app
3. Pull down to reveal the banner, or long-press the link

## Testing with Tunnel (No Server Required)

Test universal links without deploying a website using Expo's tunnel feature:

1. **Set a consistent tunnel subdomain:**

```bash
export EXPO_TUNNEL_SUBDOMAIN=my-app-name
```

2. **Configure associated domains with the ngrok URL:**

```json
{
  "expo": {
    "ios": {
      "associatedDomains": ["applinks:my-app-name.ngrok.io"]
    }
  }
}
```

3. **Build the development client:**

```bash
npx expo run:ios
```

4. **Start the dev server with tunnel:**

```bash
npx expo start --tunnel
```

5. **Test the link** - Type `https://my-app-name.ngrok.io` in Safari on your device. It should open your app directly.

The tunnel creates a public HTTPS URL that serves the AASA file automatically, letting you test the full universal links flow during development.

## Debugging

Check if Apple has cached your AASA:

```bash
curl "https://app-site-association.cdn-apple.com/a/v1/yourdomain.com"
```

Validate AASA file format:

```bash
curl https://yourdomain.com/.well-known/apple-app-site-association | jq
```

Common issues:

- AASA must be served with `Content-Type: application/json`
- HTTPS required (no self-signed certs)
- Apple caches AASA files - changes may take time to propagate

## Expo Router Integration

With Expo Router, handle incoming links automatically:

```tsx
// app/[...path].tsx handles all deep link paths
// app/products/[id].tsx handles /products/:id links
```

Access link parameters:

```tsx
import { useLocalSearchParams } from "expo-router";

export default function Product() {
  const { id } = useLocalSearchParams();
  return <Text>Product: {id}</Text>;
}
```

