Introduction

Installing an app is friction. Users scanning a QR code at a cafe want to order coffee, not download a 150MB app, create an account, and verify their email. By the time the app installs, they have ordered at the counter.

App Clips (iOS) and Instant Apps (Android) solve this by delivering focused app experiences that launch in seconds without installation. Scan a code, tap a link, and the experience starts immediately. Users who find value can then install the full app.

This guide covers building, deploying, and optimising these lightweight experiences to convert casual interactions into engaged users.

When to Use App Clips and Instant Apps

Ideal Use Cases

GOOD FIT                          | POOR FIT
----------------------------------|----------------------------------
Payment at point of sale          | Social networking
Restaurant ordering               | Games with large assets
Bike/scooter rental              | Media streaming
Event check-in                    | Productivity suites
Product demos                     | Apps requiring extensive setup
Parking meters                    | Content-heavy applications
Loyalty card scanning             | Apps with complex onboarding

Business Impact Metrics

Typical improvements seen with App Clips/Instant Apps:

  • 50-70% reduction in drop-off vs. app install flow
  • 3-5x higher conversion for spontaneous use cases
  • 30-40% of App Clip users later install full app
  • 20-30% increase in first-time transactions

iOS App Clip

iOS App Clips Implementation Infographic s Implementation

Project Setup

// 1. Add App Clip target in Xcode
// File > New > Target > App Clip

// 2. Configure Info.plist for App Clip
// NSAppClip dictionary with experience URLs

// 3. Share code between main app and App Clip using shared frameworks

App Clip Target Configuration

// AppClipApp.swift
import SwiftUI
import AppClip

@main
struct CoffeeOrderAppClip: App {
    @StateObject private var orderManager = OrderManager()
    @State private var invocationURL: URL?

    var body: some Scene {
        WindowGroup {
            ContentView(orderManager: orderManager)
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    handleInvocation(activity)
                }
        }
    }

    private func handleInvocation(_ activity: NSUserActivity) {
        guard let url = activity.webpageURL else { return }

        invocationURL = url

        // Parse location from URL
        if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
           let locationId = components.queryItems?.first(where: { $0.name == "location" })?.value {
            orderManager.setLocation(locationId)
        }
    }
}

Focused User Experience

// App Clip must be under 15MB and focused on a single task

struct ContentView: View {
    @ObservedObject var orderManager: OrderManager
    @State private var showingFullAppBanner = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // Location header
                LocationHeader(location: orderManager.currentLocation)

                // Menu (streamlined for App Clip)
                MenuView(orderManager: orderManager)

                // Cart summary
                if !orderManager.cart.isEmpty {
                    CartSummary(orderManager: orderManager)
                }
            }
            .navigationTitle("Order Coffee")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("Get Full App") {
                        showingFullAppBanner = true
                    }
                }
            }
            .appStoreOverlay(isPresented: $showingFullAppBanner) {
                SKOverlay.AppClipConfiguration(position: .bottom)
            }
        }
    }
}

// Streamlined menu - no browsing, quick selection
struct MenuView: View {
    @ObservedObject var orderManager: OrderManager

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                // Popular items first
                Section("Popular at \(orderManager.currentLocation?.name ?? "this location")") {
                    ForEach(orderManager.popularItems) { item in
                        QuickOrderItem(item: item, onAdd: {
                            orderManager.addToCart(item)
                        })
                    }
                }

                // Quick categories
                Section("Menu") {
                    ForEach(orderManager.categories) { category in
                        CategoryRow(category: category, orderManager: orderManager)
                    }
                }
            }
            .padding()
        }
    }
}

App Clip Card Configuration

// Configure App Clip experiences in App Store Connect
// Each experience needs:
// - URL pattern (e.g., https://coffee.app/order?location=*)
// - Image (1800x1200 pixels)
// - Title, subtitle, call-to-action

// Advanced App Clip Card (dynamic metadata)
struct AppClipMetadata {
    // Server endpoint returns metadata based on location
    static func fetchMetadata(for url: URL) async -> AppClipCardInfo {
        let locationId = extractLocationId(from: url)

        // Fetch location-specific info
        let location = try await api.getLocation(locationId)

        return AppClipCardInfo(
            title: "Order at \(location.name)",
            subtitle: location.address,
            callToAction: "Order Now",
            headerImage: location.headerImageURL
        )
    }
}

Location Verification

import CoreLocation
import AppClip

class LocationVerifier: NSObject, ObservableObject {
    @Published var verificationStatus: VerificationStatus = .pending

    private let locationManager = CLLocationManager()

    enum VerificationStatus {
        case pending
        case verified
        case failed(String)
    }

    func verifyLocation(for activity: NSUserActivity) {
        guard let payload = activity.appClipActivationPayload else {
            verificationStatus = .failed("No activation payload")
            return
        }

        // Get expected location from URL
        guard let url = activity.webpageURL,
              let expectedRegion = extractRegion(from: url) else {
            verificationStatus = .failed("Invalid URL")
            return
        }

        // Verify user is at expected location
        payload.confirmAcquired(in: expectedRegion) { inRegion, error in
            DispatchQueue.main.async {
                if let error = error {
                    self.verificationStatus = .failed(error.localizedDescription)
                } else if inRegion {
                    self.verificationStatus = .verified
                } else {
                    self.verificationStatus = .failed("You don't appear to be at this location")
                }
            }
        }
    }

    private func extractRegion(from url: URL) -> CLRegion? {
        // Parse coordinates from URL or fetch from server
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
              let lat = components.queryItems?.first(where: { $0.name == "lat" })?.value,
              let lng = components.queryItems?.first(where: { $0.name == "lng" })?.value,
              let latitude = Double(lat),
              let longitude = Double(lng) else {
            return nil
        }

        let center = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
        return CLCircularRegion(center: center, radius: 100, identifier: "location")
    }
}

Sign in with Apple Integration

// App Clips can use Sign in with Apple for frictionless authentication
import AuthenticationServices

struct SignInView: View {
    @Environment(\.colorScheme) var colorScheme
    @ObservedObject var authManager: AuthManager

    var body: some View {
        VStack(spacing: 20) {
            Text("Sign in to save your order")
                .font(.headline)

            SignInWithAppleButton(
                onRequest: { request in
                    request.requestedScopes = [.email]
                },
                onCompletion: { result in
                    handleSignIn(result)
                }
            )
            .signInWithAppleButtonStyle(
                colorScheme == .dark ? .white : .black
            )
            .frame(height: 50)

            // Continue without signing in
            Button("Continue as Guest") {
                authManager.continueAsGuest()
            }
            .foregroundColor(.secondary)
        }
        .padding()
    }

    private func handleSignIn(_ result: Result<ASAuthorization, Error>) {
        switch result {
        case .success(let authorization):
            if let credential = authorization.credential as? ASAuthorizationAppleIDCredential {
                authManager.signIn(with: credential)
            }
        case .failure(let error):
            print("Sign in failed: \(error)")
        }
    }
}

Android Instant Apps Im

plementation

Module Configuration

// settings.gradle.kts
include(":app")
include(":instantapp")
include(":base")        // Shared code
include(":features:order")  // Feature module

// app/build.gradle.kts (installed app)
plugins {
    id("com.android.application")
    kotlin("android")
}

android {
    namespace = "com.example.coffeeorder"

    dynamicFeatures += setOf(":features:order")

    buildTypes {
        release {
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
        }
    }
}

dependencies {
    implementation(project(":base"))
}
// instantapp/build.gradle.kts
plugins {
    id("com.android.instantapp")
}

dependencies {
    implementation(project(":features:order"))
    implementation(project(":base"))
}
// features/order/build.gradle.kts
plugins {
    id("com.android.dynamic-feature")
    kotlin("android")
}

android {
    namespace = "com.example.coffeeorder.order"

    // Configure as instant-enabled feature
    buildTypes {
        release {
            isMinifyEnabled = false // Handled by base module
        }
    }
}

dependencies {
    implementation(project(":app"))
    implementation(project(":base"))
}

AndroidManifest Configuration

{/* features/order/src/main/AndroidManifest.xml */}
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:dist="http://schemas.android.com/apk/distribution">

    <dist:module
        dist:instant="true"
        dist:title="@string/title_order">
        <dist:delivery>
            <dist:install-time />
        </dist:delivery>
        <dist:fusing dist:include="true" />
    </dist:module>

    <application>
        <activity
            android:name=".OrderActivity"
            android:exported="true"
            android:launchMode="singleTask">

            {/* App Links for instant app */}
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data
                    android:scheme="https"
                    android:host="coffee.app"
                    android:pathPattern="/order.*" />
            </intent-filter>

            {/* Deep link for instant app */}
            <meta-data
                android:name="default-url"
                android:value="https://coffee.app/order" />
        </activity>
    </application>
</manifest>

Instant App Entry Point

// features/order/src/main/java/OrderActivity.kt
class OrderActivity : ComponentActivity() {

    private val viewModel: OrderViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Parse deep link
        val locationId = intent.data?.getQueryParameter("location")
        locationId?.let { viewModel.setLocation(it) }

        setContent {
            CoffeeOrderTheme {
                OrderScreen(
                    viewModel = viewModel,
                    onInstallFullApp = { promptInstall() }
                )
            }
        }
    }

    private fun promptInstall() {
        // Show install prompt for full app
        val intent = Intent(Intent.ACTION_VIEW).apply {
            data = Uri.parse("market://details?id=${packageName}")
            setPackage("com.android.vending")
        }

        // Use Play Core library for seamless install
        if (InstantApps.isInstantApp(this)) {
            InstantApps.showInstallPrompt(
                this,
                intent,
                REQUEST_INSTALL,
                "Get the full app for rewards and order history"
            )
        }
    }

    companion object {
        private const val REQUEST_INSTALL = 1001
    }
}

Streamlined UI for Instant Experience

@Composable
fun OrderScreen(
    viewModel: OrderViewModel,
    onInstallFullApp: () -> Unit
) {
    val uiState by viewModel.uiState.collectAsState()
    val context = LocalContext.current
    val isInstantApp = remember { InstantApps.isInstantApp(context) }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Order Coffee") },
                actions = {
                    if (isInstantApp) {
                        TextButton(onClick = onInstallFullApp) {
                            Text("Get Full App")
                        }
                    }
                }
            )
        },
        bottomBar = {
            if (uiState.cart.isNotEmpty()) {
                CartBottomBar(
                    cart = uiState.cart,
                    onCheckout = { viewModel.checkout() }
                )
            }
        }
    ) { padding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
        ) {
            // Location info
            uiState.location?.let { location ->
                LocationCard(location = location)
            }

            // Quick order section - most popular items
            QuickOrderSection(
                items = uiState.popularItems,
                onAddItem = { viewModel.addToCart(it) }
            )

            // Simple menu categories
            LazyColumn {
                items(uiState.categories) { category ->
                    CategorySection(
                        category = category,
                        onAddItem = { viewModel.addToCart(it) }
                    )
                }
            }
        }
    }
}

@Composable
fun QuickOrderSection(
    items: List<MenuItem>,
    onAddItem: (MenuItem) -> Unit
) {
    Column {
        Text(
            text = "Quick Order",
            style = MaterialTheme.typography.titleMedium,
            modifier = Modifier.padding(16.dp)
        )

        LazyRow(
            contentPadding = PaddingValues(horizontal = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            items(items) { item ->
                QuickOrderCard(
                    item = item,
                    onAdd = { onAddItem(item) }
                )
            }
        }
    }
}

Size Optimization

// Keep instant app under 15MB by:
// 1. Using vector drawables instead of PNGs
// 2. Deferring image loading
// 3. Using Play Feature Delivery for on-demand assets

// build.gradle.kts
android {
    bundle {
        language {
            enableSplit = true
        }
        density {
            enableSplit = true
        }
        abi {
            enableSplit = true
        }
    }

    // Shrink resources aggressively
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

// Use R8 full mode for maximum size reduction
// gradle.properties
android.enableR8.fullMode=true

Invocation Methods

QR Codes and NFC Tags

// iOS: Generate App Clip Code (visual codes)
// App Clip Codes are generated in App Store Connect
// They encode the URL and can be scanned by camera

// For standard QR codes, encode your App Clip URL
func generateQRCode(for locationId: String) -> UIImage? {
    let urlString = "https://coffee.app/order?location=\(locationId)"

    guard let data = urlString.data(using: .utf8) else { return nil }

    let filter = CIFilter.qrCodeGenerator()
    filter.setValue(data, forKey: "inputMessage")
    filter.setValue("H", forKey: "inputCorrectionLevel")

    guard let ciImage = filter.outputImage else { return nil }

    let transform = CGAffineTransform(scaleX: 10, y: 10)
    let scaledImage = ciImage.transformed(by: transform)

    return UIImage(ciImage: scaledImage)
}
// Android: NFC tag writing for Instant App
class NfcWriter {

    fun writeInstantAppTag(tag: Tag, locationId: String): Boolean {
        val url = "https://coffee.app/order?location=$locationId"
        val record = NdefRecord.createUri(url)
        val message = NdefMessage(arrayOf(record))

        return try {
            val ndef = Ndef.get(tag)
            ndef?.connect()
            ndef?.writeNdefMessage(message)
            ndef?.close()
            true
        } catch (e: Exception) {
            Log.e("NfcWriter", "Failed to write tag", e)
            false
        }
    }
}

Smart App Banners

{/* Website: Smart App Banner for iOS */}
<meta name="apple-itunes-app" content="
  app-id=123456789,
  app-clip-bundle-id=com.example.coffeeorder.Clip,
  app-clip-display=card
">

{/* Android: Instant App link */}
<a href="https://coffee.app/order?location=123" rel="alternate">
  Order Coffee
</a>

Conversion Optimization

Encouraging Full App Install

// iOS: Strategic install prompts
class InstallPromptManager {

    private let defaults = UserDefaults.standard
    private let orderCountKey = "app_clip_order_count"

    func shouldShowInstallPrompt() -> Bool {
        let orderCount = defaults.integer(forKey: orderCountKey)

        // Show after 2nd successful order
        return orderCount >= 2
    }

    func recordOrder() {
        let current = defaults.integer(forKey: orderCountKey)
        defaults.set(current + 1, forKey: orderCountKey)
    }

    func showInstallPrompt(in viewController: UIViewController) {
        let config = SKOverlay.AppClipConfiguration(position: .bottom)
        let overlay = SKOverlay(configuration: config)
        overlay.present(in: viewController.view.window!.windowScene!)
    }
}

// Value proposition view
struct GetFullAppView: View {
    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: "star.fill")
                .font(.system(size: 40))
                .foregroundColor(.yellow)

            Text("Get More with the Full App")
                .font(.headline)

            VStack(alignment: .leading, spacing: 8) {
                BenefitRow(icon: "gift", text: "Earn rewards on every order")
                BenefitRow(icon: "clock", text: "Save your favorites")
                BenefitRow(icon: "bell", text: "Get notified about deals")
                BenefitRow(icon: "creditcard", text: "Faster checkout")
            }

            Button("Download Free") {
                // Show App Store overlay
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

Data Handoff to Full App

// Android: Transfer data when user installs full app
class DataTransferManager(private val context: Context) {

    private val prefs = context.getSharedPreferences("instant_app_data", Context.MODE_PRIVATE)

    // Save data in instant app
    fun saveForTransfer(userId: String?, orderHistory: List<Order>) {
        val data = TransferData(
            userId = userId,
            recentOrders = orderHistory.take(10),
            savedLocations = getSavedLocations()
        )

        prefs.edit()
            .putString("transfer_data", Json.encodeToString(data))
            .apply()

        // Also use Cookie API for cross-install transfer
        if (InstantApps.isInstantApp(context)) {
            val cookieData = Json.encodeToString(data).toByteArray()
            InstantApps.getPackageManagerCompat(context)
                .instantAppCookie = cookieData
        }
    }

    // Read data in installed app
    fun readTransferredData(): TransferData? {
        // First check Cookie API
        val cookieData = InstantApps.getPackageManagerCompat(context)
            .instantAppCookie

        if (cookieData.isNotEmpty()) {
            return try {
                Json.decodeFromString(String(cookieData))
            } catch (e: Exception) {
                null
            }
        }

        // Fall back to SharedPreferences
        val json = prefs.getString("transfer_data", null)
        return json?.let { Json.decodeFromString(it) }
    }
}

@Serializable
data class TransferData(
    val userId: String?,
    val recentOrders: List<Order>,
    val savedLocations: List<Location>
)

Analytics and Measurement

Tracking App Clip Performance

// Track key metrics
class AppClipAnalytics {

    func trackLaunch(source: LaunchSource, locationId: String?) {
        Analytics.track("app_clip_launched", properties: [
            "source": source.rawValue,
            "location_id": locationId ?? "unknown",
            "is_first_launch": isFirstLaunch
        ])
    }

    func trackConversion(type: ConversionType, value: Double?) {
        Analytics.track("app_clip_conversion", properties: [
            "type": type.rawValue,
            "value": value ?? 0,
            "session_duration": sessionDuration
        ])
    }

    func trackInstallPromptShown() {
        Analytics.track("app_clip_install_prompt_shown")
    }

    func trackInstallInitiated() {
        Analytics.track("app_clip_install_initiated")
    }

    enum LaunchSource: String {
        case qrCode = "qr_code"
        case nfc = "nfc"
        case appClipCode = "app_clip_code"
        case safari = "safari"
        case messages = "messages"
        case maps = "maps"
    }

    enum ConversionType: String {
        case order = "order"
        case signup = "signup"
        case appInstall = "app_install"
    }
}

Conclusion

App Clips and Instant Apps remove the biggest barrier to mobile engagement: installation. Users get immediate value, and you get a chance to demonstrate that value before asking for a larger commitment.

Key success factors:

  1. Focus ruthlessly - One task, done exceptionally well
  2. Optimise size - Under 15MB, ideally under 10MB
  3. Minimise friction - No account required for first interaction
  4. Provide clear value - Solve the immediate need
  5. Convert strategically - Show full app benefits at the right moment

Start with your highest-frequency, lowest-commitment use case. Measure conversion through the funnel. Iterate on the experience based on where users drop off.

The goal is not just instant access - it is building a bridge from casual user to loyal customer.


Planning to implement App Clips or Instant Apps for your Australian business? We have deployed instant experiences for retail, hospitality, and service businesses. Contact us to discuss your use case.