```html SwiftUI: How to Build an App Clip (iOS 17+, 2026)

How to Build an App Clip in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: App Clips, SKOverlay, NSUserActivity Updated: May 11, 2026
TL;DR

Add an App Clip target in Xcode, declare your invocation URL in its Info.plist, then handle NSUserActivityTypeBrowsingWeb in your SwiftUI App entry point to read the launch URL. Attach .appStoreOverlay to nudge users toward the full app.

import SwiftUI
import StoreKit

@main
struct CoffeeClip: App {
    @State private var launchURL: URL?
    @State private var showOverlay = false

    var body: some Scene {
        WindowGroup {
            ClipRootView(url: launchURL, showOverlay: $showOverlay)
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    launchURL = activity.webpageURL
                }
                .appStoreOverlay(isPresented: $showOverlay) {
                    SKOverlay.AppClipConfiguration(position: .bottom)
                }
        }
    }
}

Full implementation

The example below models a coffee-shop App Clip. When a customer taps an NFC tag or Smart App Banner, the clip launches, parses the menu item from the invocation URL, and lets them place a quick order. A bottom SKOverlay card then offers the full app. The entire clip fits comfortably under iOS's 15 MB limit because it imports only SwiftUI and StoreKit — no third-party dependencies. State flows from the App entry point down to child views through a shared ClipSession observable object.

// ── CoffeeClipApp.swift (App Clip target) ──────────────────────────────
import SwiftUI
import StoreKit

@main
struct CoffeeClipApp: App {
    @State private var session = ClipSession()

    var body: some Scene {
        WindowGroup {
            ClipRootView()
                .environment(session)
                // iOS invokes clips via an NSUserActivity carrying the URL
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    session.handle(activity: activity)
                }
                // The SKOverlay card prompts download of the full app
                .appStoreOverlay(isPresented: $session.showFullAppPrompt) {
                    SKOverlay.AppClipConfiguration(position: .bottom)
                }
        }
    }
}

// ── ClipSession.swift ───────────────────────────────────────────────────
import Observation
import Foundation

@Observable
final class ClipSession {
    var itemID: String?
    var showFullAppPrompt = false

    /// Parse a URL like https://example.com/order?item=latte
    func handle(activity: NSUserActivity) {
        guard
            let url = activity.webpageURL,
            let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
            let item = components.queryItems?.first(where: { $0.name == "item" })?.value
        else { return }
        itemID = item
    }
}

// ── ClipRootView.swift ──────────────────────────────────────────────────
import SwiftUI

struct ClipRootView: View {
    @Environment(ClipSession.self) private var session

    var body: some View {
        NavigationStack {
            if let id = session.itemID {
                OrderView(itemID: id)
            } else {
                WelcomeView()
            }
        }
    }
}

// ── WelcomeView.swift ───────────────────────────────────────────────────
struct WelcomeView: View {
    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "cup.and.saucer.fill")
                .font(.system(size: 64))
                .foregroundStyle(.brown)
            Text("Tap a menu item tag to quick-order")
                .font(.headline)
                .multilineTextAlignment(.center)
        }
        .padding()
        .navigationTitle("Brew Quick Order")
    }
}

// ── OrderView.swift ─────────────────────────────────────────────────────
struct OrderView: View {
    let itemID: String
    @Environment(ClipSession.self) private var session
    @State private var ordered = false

    var body: some View {
        VStack(spacing: 24) {
            Text("Order: \(itemID.capitalized)")
                .font(.largeTitle.bold())

            Button(action: placeOrder) {
                Label("Order Now", systemImage: "checkmark.circle.fill")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.brown)
                    .foregroundStyle(.white)
                    .clipShape(RoundedRectangle(cornerRadius: 14))
            }
            .accessibilityLabel("Place order for \(itemID)")

            if ordered {
                Text("Order placed! ☕️")
                    .foregroundStyle(.secondary)
                Button("Get the full app") {
                    session.showFullAppPrompt = true
                }
                .buttonStyle(.bordered)
            }
        }
        .padding()
        .navigationTitle("Quick Order")
    }

    private func placeOrder() {
        // Call your lightweight order API here
        ordered = true
    }
}

// ── Preview ─────────────────────────────────────────────────────────────
#Preview("Welcome") {
    ClipRootView()
        .environment(ClipSession())
}

#Preview("Item Selected") {
    let s = ClipSession()
    s.itemID = "latte"
    return ClipRootView()
        .environment(s)
}

How it works

  1. @main App Clip entry point. The CoffeeClipApp struct is the root of the App Clip target — completely independent from your main app's @main. Xcode keeps them in separate targets so the clip bundle stays small.
  2. .onContinueUserActivity(NSUserActivityTypeBrowsingWeb). iOS delivers an NSUserActivity to your scene when an NFC tag, QR code, Safari Smart App Banner, or App Clip card triggers the clip. The invocation URL lives in activity.webpageURL. You parse query parameters there to understand the entry context.
  3. @Observable ClipSession. Rather than threading @Bindings through every level, a single observable session holds itemID and is injected via .environment(session). Child views observe it directly — no manual objectWillChange calls needed in Swift 5.9+.
  4. .appStoreOverlay(isPresented:configuration:). When the user completes their action (or taps "Get the full app"), flipping showFullAppPrompt to true slides up an SKOverlay.AppClipConfiguration card from the bottom. iOS uses the App Clip's bundle ID to automatically match the correct App Store listing — no App Store ID needed in code.
  5. Info.plist invocation URL prefix. In your App Clip target's Info.plist, set NSAppClip > NSAppClipRequestEphemeralUserNotification and register your URL prefix under App Store Connect > App Clips. iOS only launches the clip when the invocation URL matches a registered prefix — this is a security requirement, not optional.

Variants

Request location for nearby experiences

// App Clips can request ephemeral location (no full permission needed)
// Add NSAppClipRequestLocationConfirmation: true in Info.plist

import CoreLocation
import SwiftUI

struct NearbyOrderView: View {
    @State private var locationManager = CLLocationManager()

    var body: some View {
        VStack {
            Text("Confirm you're at our café")
            Button("Use My Location") {
                // Ephemeral authorization — user sees a compact sheet,
                // not the standard full-screen location prompt.
                locationManager.requestWhenInUseAuthorization()
            }
            .accessibilityLabel("Confirm location to enable order")
        }
        .onAppear {
            // CLAccuracyAuthorization.reducedAccuracy is default for clips
            locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
        }
    }
}

Shared UserDefaults with the main app via App Group

App Clips can share a small amount of data (up to 1 MB) with their parent app through an App Group container. Enable the App Groups capability on both targets, use the same group ID (e.g. group.com.example.coffee), then read/write with UserDefaults(suiteName: "group.com.example.coffee"). This lets the full app pre-populate the cart or loyalty ID that the clip collected — a seamless handoff without iCloud or a server round-trip.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement an App Clip in SwiftUI for iOS 17+.
Use App Clips (separate Xcode target), SKOverlay, and NSUserActivity.
Parse the invocation URL's query parameters to set initial state.
Make it accessible (VoiceOver labels on all interactive elements).
Stay under 15 MB — no third-party dependencies.
Add a #Preview with realistic sample data for both the welcome and order states.

In Soarias's Build phase, paste this prompt in the Implementation panel after locking your screens — the agent will scaffold the App Clip target, wire up the Info.plist URL prefix, and generate the SwiftUI views in one pass, leaving you to supply your real API endpoint.

Related

FAQ

Does this work on iOS 16?

App Clips themselves have been available since iOS 14. However, this guide uses @Observable (iOS 17), the #Preview macro (iOS 17 / Xcode 15), and .appStoreOverlay (iOS 14). If you need iOS 16 support, swap @Observable for ObservableObject with @Published, and replace #Preview with a PreviewProvider. The SKOverlay and NSUserActivity handling is identical on iOS 14–16.

Can an App Clip access Sign In with Apple or Apple Pay?

Yes — both are explicitly allowed in App Clips and are among the few elevated capabilities permitted. Sign In with Apple lets returning users authenticate without a password. Apple Pay enables one-tap checkout without the user handing over payment details. Add the Sign In with Apple and Apple Pay capabilities to the App Clip target (not just the main app) and they work identically to their full-app counterparts. Keychain sharing and push notifications are not available to clips without special entitlements.

What's the UIKit equivalent?

In UIKit, you'd implement application(_:continue:restorationHandler:) in your AppDelegate (or scene(_:continue:) in SceneDelegate) to receive the NSUserActivity. The SKOverlay API and SKOverlay.AppClipConfiguration are identical — they're StoreKit, not SwiftUI-specific. Present the overlay via SKOverlay.present(_:in:) passing a UIWindow. The SwiftUI .appStoreOverlay modifier is simply a wrapper around this UIKit call.

Last reviewed: 2026-05-11 by the Soarias team.

```