How to Build an App Clip in SwiftUI
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
-
@mainApp Clip entry point. TheCoffeeClipAppstruct 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. -
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb). iOS delivers anNSUserActivityto your scene when an NFC tag, QR code, Safari Smart App Banner, or App Clip card triggers the clip. The invocation URL lives inactivity.webpageURL. You parse query parameters there to understand the entry context. -
@Observable ClipSession. Rather than threading@Bindings through every level, a single observable session holdsitemIDand is injected via.environment(session). Child views observe it directly — no manualobjectWillChangecalls needed in Swift 5.9+. -
.appStoreOverlay(isPresented:configuration:). When the user completes their action (or taps "Get the full app"), flippingshowFullAppPrompttotrueslides up anSKOverlay.AppClipConfigurationcard 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. -
Info.plist invocation URL prefix. In your App Clip target's
Info.plist, setNSAppClip > NSAppClipRequestEphemeralUserNotificationand 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
- iOS 16 and earlier: App Clips launched on iOS 14, but
@Observableand the#Previewmacro require iOS 17 / Swift 5.9. If you need iOS 16 support, replace@Observablewith@StateObject/ObservableObjectandPreviewProvider. - 15 MB binary limit: The App Clip binary (not the download) must be under 15 MB uncompressed. Heavy image assets, large Swift packages, and embedded frameworks blow this instantly. Use SF Symbols, keep assets in an Asset Catalog, and avoid linking frameworks not strictly needed. Check size in Xcode > Product > Archive > Distribute > App Clip size report.
- Invocation URL must be registered first: Testing on a device before registering the URL prefix in App Store Connect causes a silent launch failure — the clip installs but never receives the
NSUserActivity. Use_XCAppClipURLenvironment variable in the scheme's Run arguments to simulate invocations in Simulator. - Accessibility:
SKOverlayis a system UI element — VoiceOver handles it automatically. But every button in your clip views needs an explicit.accessibilityLabelsince App Clip users may be first-time visitors with assistive technology enabled.
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.