← All writing

iOS

Setting Up an iOS App Clip in a React Native App

·12 min read


Part 2 of a three-part series on building an iOS App Clip from an existing React Native app. Part 1 covered the architecture and design decisions. This part is the hands-on guide: everything you have to wire up — in Xcode, in your JS bundle, on your server, and in App Store Connect — to actually stand one up. Part 3 covers keeping it under Apple's size limit.


TL;DR

  • Standing up a React Native App Clip means wiring four layers: native Xcode target → JS bundle (appClip.js) → server-side apple-app-site-association file → App Store Connect experiences. Skip one and the clip won't launch.
  • The RN-specific step most guides miss: the clip target needs its own bundle build phase that sets ENTRY_FILE="appClip.js".
  • Associated Domains go on both targets (applinks: app, appclips: clip); iterate locally with Xcode's Simulate App Clip Invocation before the server/App Store pieces exist.

Part 1 was the why and the shape. This part is the how. It's deliberately practical: every React Native App Clip — whether it shows a parking meter, a restaurant menu, a boarding pass, or (our running example) a shared event — needs the same wiring. The clip's content changes; the plumbing doesn't.

That plumbing spans four layers, and skipping any one of them means the clip won't launch:

Four layers of App Clip wiring in sequence: the native Xcode App Clip target, the appClip.js JS bundle that runs inside it, the AASA file on your server that lets iOS trust your URL, and the App Clip experiences configured in App Store Connect

This guide walks all four, in order. It assumes a bare React Native project (not Expo) and an app you already ship. Snippets are genericized — replace com.example.myapp, example.com, and target names with your own.

One prerequisite: App Clips require iOS 14+. Set the clip target's deployment target accordingly.

1. Create the App Clip target in Xcode

An App Clip is a separate target with its own bundle identifier, embedded inside your main app. Create it the standard way:

File ▸ New ▸ Target… ▸ App Clip.

Xcode scaffolds a surprising amount for you. It's worth knowing exactly what appears, because you'll touch most of it:

  • A new scheme to build and run the clip (your existing app scheme is untouched).

  • An AppDelegate for the clip target (in Swift for modern templates).

  • An Info.plist for the clip, pre-seeded with an NSAppClip dictionary.

  • An .entitlements file containing the Parent Application Identifiers entitlement — this is what ties the clip to its parent app:

    
    <key>com.apple.developer.parent-application-identifiers</key>
    <array>
        <string>$(AppIdentifierPrefix)com.example.myapp</string>
    </array>
    
  • An App Clip app identifier using your app's id as a prefix — the convention is com.example.myappcom.example.myapp.Clip.

  • An embed build phase on the main app target that packages the clip inside the app.

That last point matters: the clip ships inside your full app's binary. Users who tap a clip link get just the clip; users who install from the App Store get the app with the clip embedded.

2. Bridge the target to React Native

By default the new target is a native (Swift/SwiftUI) app. We want it to run React Native instead — specifically, a second RN root component dedicated to the clip.

There are two halves to this bridge.

Native half — in the clip's AppDelegate, start React Native with a module name of your choosing (here, AppClip):

// MyAppClip/AppDelegate.swift (essentials)
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: ...) -> Bool {
  let delegate = ReactNativeDelegate()
  let factory  = RCTReactNativeFactory(delegate: delegate)
  delegate.dependencyProvider = RCTAppDependencyProvider()

  window = UIWindow(frame: UIScreen.main.bounds)
  factory.startReactNative(
    withModuleName: "AppClip",   // ← must match the JS registration below
    in: window,
    launchOptions: launchOptions
  )
  return true
}

JS half — add a new entry file that registers a root component under that exact name:

// appClip.js
import { AppRegistry } from 'react-native';
import AppClip from './src/AppClip';

AppRegistry.registerComponent('AppClip', () => AppClip);

AppClip here is your slimmed-down clip shell — a single screen, its own providers — described in Part 1. The important thing for setup is that the string in startReactNative(withModuleName:) and the string in registerComponent() must match, or the clip launches to a blank screen.

3. Bundle the clip's JS (the step generic guides skip)

Here's the piece most tutorials gloss over. Your project already has a "Bundle React Native code and images" build phase that turns index.js into the app's JS bundle. The clip target needs its own build phase that bundles appClip.js instead.

React Native's bundling script reads an ENTRY_FILE environment variable. So in the clip target's "Bundle React Native code and images" build phase, set it before invoking the script:

# Clip target ▸ Build Phases ▸ "Bundle React Native code and images"
set -e
WITH_ENVIRONMENT="$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh"
REACT_NATIVE_XCODE="$REACT_NATIVE_PATH/scripts/react-native-xcode.sh"

# Use the App Clip entry point instead of the default index.js
export ENTRY_FILE="appClip.js"

/bin/sh -c "$WITH_ENVIRONMENT $REACT_NATIVE_XCODE"

Without this, your clip would either fail to build or (worse) silently bundle the full app's JS — which defeats the entire point and blows the size budget from Part 3.

Size tip (revisited in Part 3): this is also the natural place to compress the release bundle — e.g. appending a step that runs a tool like react-native-compressed-jsbundle after the standard bundling. Every kilobyte counts when your ceiling is ~15 MB.

4. The dev workflow: loading the bundle from Metro

In release builds the clip loads a pre-built main.jsbundle from disk. In development you want it to load from Metro with fast refresh, exactly like your app — except the clip has its own bundle root (appClip), so you point the clip's bundleURL at appClip.bundle:

// MyAppClip/AppDelegate.swift — bundle resolution
override func bundleURL() -> URL? {
  #if DEBUG
    // Metro serves each root at /{bundleRoot}.bundle
    return RCTBundleURLProvider.sharedSettings()
             .jsBundleURL(forBundleRoot: "appClip")
  #else
    return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
  #endif
}

Two practical wrinkles worth planning for:

  • Running on a physical device (as opposed to the simulator) means Metro isn't on localhost — the device has to reach your Mac's IP. A clean way to handle this is to read the host from Info.plist keys you can set per build (e.g. APP_CLIP_HOST_IP and a APP_CLIP_DEVICE_TYPE flag) and branch in bundleURL(). This saves you from hard-coding an IP that changes every time you switch networks.
  • Ports. If your app's Metro runs on a non-default port, make sure the clip's bundleURL targets the same one.

5. Entitlements & Info.plist

Two capabilities make an App Clip an App Clip.

Parent Application Identifiers (from step 1) links the clip to the app. Already done by Xcode.

Associated Domains is how iOS trusts a URL to launch your clip. Apple's rule:

Add the Associated Domains entitlement to both the app and the App Clip targets.

The prefixes differ by purpose. Your full app uses applinks: (universal links); your clip uses appclips::


<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:example.com</string>
</array>

<key>com.apple.developer.associated-domains</key>
<array>
    <string>appclips:example.com</string>
</array>

In the clip's Info.plist, the NSAppClip dictionary is required, and you declare only the permissions the clip actually uses (each with its usage-description string):

<key>NSAppClip</key>
<dict>
    <key>NSAppClipRequestEphemeralUserNotification</key><false/>
    <key>NSAppClipRequestLocationConfirmation</key><false/>
</dict>

<key>NSCameraUsageDescription</key>
<string>Used to add photos to the event.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Used to save photos to your library.</string>

You can optionally register a custom URL scheme (CFBundleURLTypes) for the clip, but the appclips: associated domain is what powers real-world invocation.

6. The Podfile: a second target

CocoaPods needs to know about the new target. Declare it alongside the app; the two share the React Native setup:

target 'MyApp' do
  config = use_native_modules!
  # ...full app pods...
end

target 'MyAppClip' do
  config = use_native_modules!
  # ...shared React Native config...
end

# Then: `pod install`

This is the minimal skeleton. In Part 3 this Podfile becomes the main lever for controlling the clip's size — the clip target excludes the heavy native modules the app links, which is the single biggest factor in fitting the budget.

7. Server side: the apple-app-site-association (AASA) file

The entitlement declares intent; your server proves it. iOS will only let a URL launch your clip if the domain hosts an apple-app-site-association (AASA) file that names your clip.

Host it (no extension, Content-Type: application/json) at:

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

It needs an appclips key whose value is <Apple Team ID>.<App Clip bundle id>. A file that supports both universal links (app) and clip invocation looks like:

{
  "applinks": {
    "details": [
      { "appIDs": ["TEAMID.com.example.myapp"],
        "components": [{ "/": "/event/*" }] }
    ]
  },
  "appclips": {
    "apps": ["TEAMID.com.example.myapp.Clip"]
  }
}

Apple's system fetches and caches this via its CDN. You can confirm it's being served and parsed correctly by checking:

https://app-site-association.cdn-apple.com/a/v1/example.com

If the AASA isn't right, your clip can only be launched from the Apple-generated App Store URL — not your own domain, QR code, or NFC tag. This is the single most common reason a "finished" App Clip won't launch in the wild. (See Apple's Associating your App Clip with your website.)

8. App Store Connect: App Clip experiences

Finally, you tell App Store Connect how the clip is discovered and what its launch card looks like. There are two kinds of experiences:

  • Default experience (required) — invoked from Safari, Messages, NFC, QR, and links in other apps via an Apple-provided App Clip link. When you upload a build with a clip and configure this, App Store Connect auto-generates a default App Clip link and a demo URL (active once the version is approved).
  • Advanced experiences (optional) — map specific invocation URLs (and even physical locations) to tailored App Clip cards. You register each URL under Advanced App Clip Experiences and provide its card metadata.

The card metadata — header image, subtitle, action ("Open", "View", "Play") — is what the user sees before the clip downloads.

Two behaviors worth internalizing:

  • The installed app always wins. If the full app is already on the device, tapping the link opens the app, not the clip.
  • Default vs. advanced routing differs by surface. Safari tends to resolve advanced-experience cards for matching URLs; Messages may fall back to the default card. Test both.

(See Apple's Configuring App Clip experiences and the App Store Connect Help pages for default and advanced experiences.)

9. Receiving the invocation URL in JavaScript

When the clip launches, iOS hands it the invocation URL as a web-browsing activity. Forward that into React Native's Linking so your JS can read it:

// MyAppClip/AppDelegate.swift
func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: ...) -> Bool {
  if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
    return RCTLinkingManager.application(application,
              continue: userActivity, restorationHandler: restorationHandler)
  }
  return false
}

On the JS side, the clip shell reads the URL on launch and parses whatever it needs (in Part 1's example, an invite key):

Linking.getInitialURL().then(url => url && handleDeepLink(url));
Linking.addEventListener('url', ({ url }) => handleDeepLink(url));

This is the handoff point where setup ends and the app logic from Part 1 takes over.

10. Test without shipping

You don't need a live AASA or an approved build to test locally. Xcode gives you two options:

  • Simulate App Clip Invocation. With the clip scheme selected, set a test invocation URL and run — Xcode injects it via the _XCAppClipURL environment variable so your bundleURL/Linking path behaves as if launched from the wild. (You can also set the invocation URL directly in the scheme's run options.)
  • On a real device, use a QR code or NFC tag pointing at your invocation URL once the AASA is live.

Start with the simulated invocation — it lets you iterate on the JS and the URL parsing long before the server/App Store Connect pieces are in place.

Setup checklist

A copy-pasteable summary of the whole wiring:

  • App Clip target created; deployment target iOS 14+; bundle id …myapp.Clip.
  • startReactNative(withModuleName: "AppClip")AppRegistry.registerComponent('AppClip', …).
  • appClip.js entry file added; AppClip shell component built (Part 1).
  • Clip's "Bundle React Native code and images" phase sets ENTRY_FILE="appClip.js".
  • Debug bundleURL()appClip.bundle from Metro (with device-IP handling); release → main.jsbundle.
  • Parent Application Identifiers entitlement (auto) + Associated Domains on both targets (applinks: app, appclips: clip).
  • NSAppClip dict + only-needed usage descriptions in the clip Info.plist.
  • Second target in the Podfile; pod install.
  • AASA hosted at /.well-known/apple-app-site-association with appclips = TEAMID.<clip id>; verified via Apple's CDN.
  • App Clip experience(s) configured in App Store Connect.
  • Invocation URL forwarded to RN Linking; JS reads getInitialURL().
  • Tested via Simulate App Clip Invocation / _XCAppClipURL.

With the clip standing up and launching, the last question is whether it fits. React Native plus native modules can easily blow past Apple's ceiling — so Part 3: Fitting React Native into a 15 MB App Clip → is about the diet that makes all of this shippable.


Sources

Code in this article is simplified and genericized for illustration; hostnames, identifiers, team ids, and credentials are placeholders.