onebox

Guide 15 of 30

For your agent: .md · all guides

On this page
  1. What it costs
  2. How it fits together
  3. Part 1: links that open the app
  4. Part 2: a share extension
  5. Privacy
  6. Where the values go
  7. Check it works
  8. Common errors

Share links and a share extension

Runs on: your Mac (the app and EAS), and your server (the API on the box, or your hosted backend). The phone tests need a real iPhone.

This guide covers sharing in both directions:

You need it only when the app has items worth sending to a friend, or reads content from the web.

What it costs

ItemCostNotes
Universal links (applinks:)includedPart of the Apple Developer Program.
A domainabout 10 USD a yearYou likely have one already (domain.md).
expo-share-intentfreeAn open-source config plugin. It adds a second iOS target.

Both parts are native changes. Each needs a new build. Neither can ship in an EAS Update (ship-an-update.md).

How it fits together

app: Share  ──>  https://example.com/item/123
                    │
     friend has the app? ── yes ──>  iOS opens the app at /item/123
                    │
                    no ──>  your server: a page with Open Graph tags and an App Store link

the preview card:  iMessage, WhatsApp, Slack  ──GET /item/123──>  the same page, read for og:* tags
the association:   Apple's CDN  ──GET /.well-known/apple-app-site-association──>  your server

A universal link is a normal https:// link that iOS opens in your app instead of Safari. iOS does this only when two halves match: the app claims the domain (the entitlement), and the domain names the app (the association file). If either half is missing, the link opens Safari. Nothing warns you.

Pick one path pattern for shared items, for example https://example.com/item/<id>. Use the same host the app already talks to, or your site’s host. Use an id that is hard to guess (a UUID), not 1, 2, 3.

Share it with React Native’s share sheet:

import { Share } from "react-native";

export const shareItem = (id: string, name: string) =>
  Share.share({ url: `https://example.com/item/${id}`, message: name });

Do not share a custom-scheme link (myapp://item/123). Messages and WhatsApp show it as plain text, it gets no preview card, and it does nothing for a friend without the app.

2. Claim the domain in the app config

{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.example.myapp",
      "associatedDomains": ["applinks:example.com"]
    }
  }
}

3. Serve the association file

Your server answers at exactly https://example.com/.well-known/apple-app-site-association:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.example.myapp"],
        "components": [{ "/": "/item/*" }]
      }
    ]
  }
}

ABCDE12345 is your Team ID (apple-developer.md). The app ID is <Team ID>.<bundle ID>. The older form, with appID and a paths list, still works too.

Apple’s rules (checked 2026-10-15 at https://developer.apple.com/documentation/xcode/supporting-associated-domains and https://developer.apple.com/documentation/technotes/tn3155-debugging-universal-links):

Each host in associatedDomains serves its own copy. A redirect from example.com to www.example.com does not count.

On the box (backend.md), add one anonymous route to the API that returns this JSON. On a hosted backend (hosted-backend.md), serve it from a function or from your site’s host; the file only has to be on the domain in associatedDomains.

The trap: the entitlement can ship in an App Store build for months while nothing serves this file. Every shared link then opens Safari, and no error shows anywhere. Check the file before the first release.

4. Let Expo Router open the screen

Expo Router routes an incoming universal link by its path. The link https://example.com/item/123 opens src/app/item/[id].tsx with id = "123". If the app has no such screen yet, add it. The screen loads the item from the API as usual, so the server still checks access.

5. A public page per item, with a preview card

The same URL must also work in a browser. iMessage, WhatsApp and Slack fetch it to build the card. A friend without the app opens it in Safari.

Serve plain HTML from the server. The preview fetchers do not run JavaScript, so the tags must be in the HTML the server sends:

<!doctype html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lemon cake - My App</title>
<meta property="og:title" content="Lemon cake">
<meta property="og:description" content="A short line about the item.">
<meta property="og:image" content="https://example.com/images/123.jpg">
<meta property="og:url" content="https://example.com/item/123">
<meta property="og:type" content="article">
<meta name="twitter:card" content="summary_large_image">
<meta name="apple-itunes-app" content="app-id=1234567890">
</head><body>
<h1>Lemon cake</h1>
<p>A short line about the item.</p>
<a href="https://apps.apple.com/app/id1234567890">Get My App on the App Store</a>
<a href="myapp://item/123">Open in My App</a>
</body></html>

This page is public. Serve only items the owner chose to share, for example a shared flag the share button sets. A private item, or a missing one, gets a plain 404 page with no og: tags, so a dead link shows no card.

Part 2: a share extension

A share extension puts your app in the iOS share sheet. expo-share-intent adds it as a second iOS target, with an App Group that hands the share to the app.

Checked 2026-10-15 at https://github.com/achorein/expo-share-intent.

1. Install it

Pick the version for your Expo SDK from the table in its README (SDK 55 needs 6.0 or later). From the Expo app folder:

npx expo install expo-share-intent expo-linking

app.json:

{
  "expo": {
    "scheme": "myapp",
    "plugins": [
      ["expo-share-intent", {
        "iosActivationRules": {
          "NSExtensionActivationSupportsWebURLWithMaxCount": 1,
          "NSExtensionActivationSupportsWebPageWithMaxCount": 1
        }
      }]
    ]
  }
}

2. Receive the share with Expo Router

Wrap the root layout in ShareIntentProvider, before any other provider. Then read useShareIntentContext() on the screen that handles the share.

The share opens the app with a deep link, and Expo Router tries to route it. Send it to your share screen in src/app/+native-intent.ts:

import { getShareExtensionKey } from "expo-share-intent";

export function redirectSystemPath({ path }: { path: string; initial: boolean }) {
  if (path.includes(`dataUrl=${getShareExtensionKey()}`)) return "/share";
  return path;
}

The README has a full Expo Router example in example/expo-router.

3. Make the config fail when the plugin is missing

If someone removes the plugin entry, or a merge drops its options, the extension can still build and still share. It just stops carrying what the app needs, and nothing says so. Make the app config throw instead. In app.config.js, next to app.json:

module.exports = ({ config }) => {
  const found = (config.plugins ?? []).some(
    (p) => (Array.isArray(p) ? p[0] : p) === "expo-share-intent",
  );
  if (!found) throw new Error("app.config.js: expo-share-intent is missing from plugins.");
  return config;
};

Now every expo config, prebuild and build stops with that line.

The same file is a good place to add preprocessorInjectJS to the plugin’s options. That script runs inside the shared page in Safari, before the extension gets it. It can add more values to shareIntent.meta, for example the page’s JSON-LD or its main image. Keep the script in its own .js file, so the app and the config read one copy.

4. Read the page on the phone, not on the server

Many sites block server fetches. Cloudflare challenges them, and some publishers answer bots with an error status. The same page loads fine in WebKit on the user’s phone.

So when the app needs the page content:

Then send the result to your API. The server never fetches the page. Read only the page the user chose to share.

5. Signing the second target

The extension has its own bundle ID (com.example.myapp.share-extension) and an App Group (group.com.example.myapp). On the first eas build after you add the plugin, EAS asks to set up credentials for the extension too. Say yes. The README warns that EAS must see only one extension target: keep extra.eas.projectId in app.json and an eas.json file, so Expo does not add its own appExtensions entry.

Privacy

The share page and the association file are public by design. Add a line to your privacy policy (privacy-and-support-pages.md):

When you share an item, anyone with the link can see its name, description and photo on a web page. You can stop sharing it at any time.

If the app sends a shared web page to your server or an AI service, say that too (ai-consent.md).

Where the values go

ValueWhere
The share hostios.associatedDomains as applinks:example.com
Team ID and bundle IDappIDs in the association file
The App Store IDsubmit.production.ios.ascAppId in eas.json; the share page uses it
The share extension optionsthe expo-share-intent entry in app.json
The page scriptits own .js file, read by app.config.js

Check it works

Test universal links on a real iPhone. Apple’s test steps use a device.

Common errors

Wrong or out of date? Fix it on GitHub.